Partial SSR rendering with fast hydration process
This article is about workaround for common problem with ASPNetCore Angular SPA applications. If you read this article, then I assume you know what is SPA applications, CSR and SSR renderings, and that SSR rendering not supported for dotnet SPA appliations for latest dotnet versions (since dotnet 6). This article is about solving partial SSR problem, by updating Title and Meta tags for specific routes, which is required for google bots or when sharing your specific urls into social sites, and you need appropriate previews.
If you need more context on issue, then please check https://github.com/dotnet/aspnetcore/issues/39295
Idea of workaround is to proxy angular routes into our api, and from the api return server rendered html with Title and Meta tags updated(if the exist on static index.html)/added.
This is Angular routes I have for my application
RouterModule.forRoot([
{ path: '', component: HomeComponent, pathMatch: 'full' },
{ path: 'counter', component: CounterComponent },
{ path: 'fetch-data', component: FetchDataComponent },
{ path: 'item/:id', component: ItemsComponent },
])
I’d like to have ‘fetch-data’ and ‘item/:id’ routes to rendered on server side, so I can get Title and Meta tags updated on server side.
Idea of workaround, is to proxy above mentioned routes into aspnet web api controller, and generate html file there. Of course it is not full SSR rendering, but at least it has advantage over hydration process in case of full SSR rendering ( https://www.angulararchitects.io/en/blog/complete-guide-for-server-side-rendering-ssr-in-angular/ ). In case of SSR, hydration process takes a while, and until it completed, some buttons are not reacting to clicks. Event Replay introduced on a latest Angular 18, but it is still in Developer Preview since NG 18.
So lats modify our proxy.conf.js file, and add above mentioned routes
const PROXY_CONFIG = [
{
context: [
"/api",
"/images",
//add those two routes
"/item",
"/fetch-data"
],
proxyTimeout: 10000,
target: target,
secure: false,
headers: {
Connection: 'Keep-Alive'
}
}
Then lats create SeoController in our webapi, and appropriate get methods for those routes
[ApiController]
[Route("")]
public class SeoController : ControllerBase
{
...
public SeoController(ILogger<SeoController> logger)
{
}
[HttpGet("item/{id}")]
public async Task<ContentResult> Get(int id)
{
...
}
[HttpGet("fetch-data")]
public async Task<ContentResult> GetFetchData()
{
...
}
After above changes, by default you will be routed to webapi endpoint, instead of angular routes. So here we gonna read the static html content, and modify the title and meta tags. To do so I’ve used AngleSharp library for parsing and modifying html dom elements. So lats create SeoService , which gonna do that job for us.
using AngleSharp;
using AngleSharp.Dom;
using AngleSharp.Html.Dom;
using Microsoft.AspNetCore.Mvc;
namespace SeoFriendlySpa.Services;
public class SeoService : ISeoService
{
private static readonly Lazy<Task<IDocument?>> HtmlDocument = new (async () =>
{
//Use the default configuration for AngleSharp
AngleSharp.IConfiguration config = Configuration.Default
.WithDefaultLoader();
//Create a new context for evaluating webpages with the given config
IBrowsingContext context = BrowsingContext.New(config);
var address = $"https://{BaseAddres}";
//read the static html from base address on a first request, using AngleSharp
var document = await context.OpenAsync(address);
return document;
});
private static string BaseAddres = "";
private readonly IHttpContextAccessor _httpContextAccessor;
public SeoService(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
BaseAddres = _httpContextAccessor.HttpContext.Request.Host.Value;
}
public async Task<ContentResult> SetMetasAndGetContentResult(string title,
IDictionary<HtmlMetaTagKey, string> metasDictionary)
{
var doc = await HtmlDocument.Value;
//Clone the doc
var clonedDoc = doc.Clone() as IDocument;
//set title
clonedDoc.Title = title;
//Select all existing metas from static index.html
var metas = clonedDoc.QuerySelectorAll("meta");
var keysToExclude = new HashSet<HtmlMetaTagKey>();
foreach (IHtmlMetaElement meta in metas)
{
var metaValue = meta.Name;
if (!string.IsNullOrEmpty(metaValue))
{
var key = new HtmlMetaTagKey("name", metaValue);
if (metasDictionary.TryGetValue(key, out var content))
{
meta.Content = content;
keysToExclude.Add(key);
}
}
metaValue = meta.Attributes["property"]?.Value;
if (!string.IsNullOrEmpty(metaValue))
{
var key = new HtmlMetaTagKey("property", metaValue);
if (metasDictionary.TryGetValue(key, out var content))
{
meta.Content = content;
keysToExclude.Add(key);
}
}
}
var metKeysToAdd = metasDictionary.Keys.Except(keysToExclude).ToList();
//Add tags, which are not presented in index.html
foreach (var htmlMetaTagKey in metKeysToAdd)
{
var meta = clonedDoc.CreateElement("meta") as IHtmlMetaElement;
meta.SetAttribute(htmlMetaTagKey.Name, htmlMetaTagKey.Value);
meta.Content = metasDictionary[htmlMetaTagKey];
clonedDoc.Head.AppendChild(meta);
}
return new ContentResult()
{
ContentType = "text/html",
Content = clonedDoc.DocumentElement.OuterHtml
};
}
}
public record HtmlMetaTagKey(string Name, string Value);
And register it as a singleton
builder.Services.AddSingleton<ISeoService, SeoService>();
So, now we can inject this service into our SeoController and get the rendered html content
using Microsoft.AspNetCore.Mvc;
using SeoFriendlySpa.Services;
namespace SeoFriendlySpa.Controllers;
[ApiController]
[Route("")]
public class SeoController : ControllerBase
{
private readonly ILogger<SeoController> _logger;
private readonly IItemsService _itemsService;
private readonly ISeoService _seoService;
public SeoController(ILogger<SeoController> logger,
IItemsService itemsService,
ISeoService seoService)
{
_logger = logger;
_itemsService = itemsService;
_seoService = seoService;
}
[HttpGet("item/{id}")]
public async Task<ContentResult> Get(int id)
{
//get item
var item = await _itemsService.GetItem(id);
return await _seoService.SetMetasAndGetContentResult(item.Title,
new Dictionary<HtmlMetaTagKey, string>()
{
{new HtmlMetaTagKey("name", "description"), item.Content},
{new HtmlMetaTagKey("name", "keywords"), $"buy in USA {item.Title}"},
{new HtmlMetaTagKey("property", "og:image"), item.ImagePath},
});
}
[HttpGet("fetch-data")]
public async Task<ContentResult> GetFetchData()
{
return await _seoService.SetMetasAndGetContentResult("Fetch data title set from the server",
new Dictionary<HtmlMetaTagKey, string>()
{
{new HtmlMetaTagKey("name", "description"), "weather forecasts"},
{new HtmlMetaTagKey("name", "keywords"), $"weather USA"},
});
}
}
That’s it!! All you need to do , is for any angular route you need this, just add in proxy.conf.js , and add new get action in SeoController.
Complete source code sample available at https://github.com/hflexgrig/SeoFriendlySpa
You can check live version deployer https://angularssrtesting1.azurewebsites.net/
And testing in https://developers.facebook.com/tools/debug
Or at Telegram message WebPage bot
As you see, now title and image previews are visible!
But wait… all this are cool, but we have another issue now. We’re querying data for the same item on a server side, as well as on frontend side, once the scripts are loaded. So how to solve that problem, to query data once, only on server side and reuse it on client side?
Solution is simple! In app.module.ts lats add injectable data on providers
@NgModule({
declarations: [
...
],
imports: [
...
],
providers: [
{provide: "itemData", useValue: executeFunction("getItemData")}
],
bootstrap: [AppComponent]
})
export class AppModule { }
function executeFunction(funcName:string){
return eval('typeof ' + funcName) === 'function' ? eval(funcName)() : "";
}
So now we can inject itemData into our ItemService
@Injectable({
providedIn: 'root'
})
export class ItemService {
private url = `${environment.apiurl}items/`
private http = inject(HttpClient);
constructor(@Inject("itemData") private data: any) {
}
getItem(id: number){
if(this.data && this.data.id == id){
const cloned = this.data;
//once data is read from server, we don't need it anymore!
this.data = undefined;
return of(cloned);
}
return this.http.get<Item>(`${this.url}${id}`);
}
}
Now, to add a data, again we’re gonna use AngleSharp, and create <script> element, appended into HTML dom’s body childs.
Lats modify SeoServices SetMetasAndGetContentResult method, to add functionData parameter there
public async Task<ContentResult> SetMetasAndGetContentResult(string title,
IDictionary<HtmlMetaTagKey, string> metasDictionary,
IDictionary<string, object>? functionData = null)
{
var doc = await HtmlDocument.Value;
var clonedDoc = doc.Clone() as IDocument;
...
if (functionData?.Any() == true)
{
var script = clonedDoc.CreateElement<IHtmlScriptElement>();
var sb = new StringBuilder();
var serializeOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true
};
foreach (var (key, value) in functionData)
{
var dataSerialized = System.Text.Json.JsonSerializer.Serialize(value, serializeOptions);
sb.Append($"function {key}(){{return {dataSerialized};}}");
}
script.TextContent = sb.ToString();
clonedDoc.Body.AppendElement(script);
}
return new ContentResult()
{
ContentType = "text/html",
Content = clonedDoc.DocumentElement.OuterHtml
};
}
And finally lats add functionData on SeoController
[HttpGet("item/{id}")]
public async Task<ContentResult> Get(int id)
{
var item = await _itemsService.GetItem(id);
return await _seoService.SetMetasAndGetContentResult(item.Title,
new Dictionary<HtmlMetaTagKey, string>()
{
{new HtmlMetaTagKey("name", "description"), item.Content},
{new HtmlMetaTagKey("name", "keywords"), $"buy in USA {item.Title}"},
{new HtmlMetaTagKey("property", "og:image"), item.ImagePath},
},
new Dictionary<string, object>()
{
{"getItemData", item}
});
}
Thats it!
Enjoy!