When a dev team maintains a Single-Page Application (SPA) front-end and .NET Web API back-end, what are the options to host the two beasts?
1. Hosting options
It’d be a no-brainer for just a Web API – use solutions like Azure App Service. An open-and-shut case. But when you bring a SPA into the picture, there are usually two options:
Option 1. Hosting SPA independently from the Web API.
Preferably, on a Content Delivery Network (CDN) that lets you use your domain name, has configurable caching policies, and all other modern attributes like 100% SLA, etc. For Azure clients, a Static Web App can be a decent choice.
If you need to scale the front-end independently from the back-end, then CDN is a go-to option. But hold your horses for a moment. We’re talking about an enterprise app with a substantial portion of returned users where the SPA assets often come from the browser cache.
Still need to scale independently? OK, then, what about the downsides? Though none of them is a deal-breaker, they all add up and may outweigh the pros of CDN.
The biggest one is a non-immutable front-end bundle.
As any part of the solution, SPA relies on environment variables (e.g. the URL of the Web API) that differ between your environments (usually, development, staging/testing and production).
Of course, you may decouple the variables to a separate JSON file and have a round trip to resolve them. Suboptimal though. The delay here is mostly coming from two places:
- another HTTP request (which is less of a problem on HTTP/2);
- running a bit extra of JavaScript on the startup due to sending that request occurs after bootstrapping the app, and then it has to alter the state based on the received variables.
Rebuilding the front-end for each environment for the sake of injecting variables is even less appealing (think of additional points of failure it introduces).
We can add a bunch of minor concerns like
- desynchronised deployment of the two essential parts of a single solution can be sometimes tricky and, if not done right, then leads to intermittent errors when the two get out of sync;
- extra resources (human hours) to maintain the distributed infrastructure (CDN brings one more point of failure that sooner or later will require attention);
- a need for an additional
OPTIONS
request each time to satisfy the browser’sCORS
feature (though latency over HTTP/2 is negligible).
These concerns may lead us to consider another option.
Option 2. Hosting SPA from the Web API
Kestrel web server can host the front-end along with the .NET Web API. That would require a bit of code in your back-end project.
This solution quickly eliminates the last 3 concerns:
- not splitting vitals part of one solution simplifies deployment;
- less moving parts lead to easier maintenance;
- CORS is not involved, as the front-end is hosted on the same domain.
However, achieving the two goals below need a deeper analysis:
- immutability of the front-end bundle;
- a custom caching policy for various front-end parts.
2. SPA hosted by .NET Web API
Feel free to jump straight to the example project on GitHub
Starting from .NET 2.x (still the case for .NET 7), there are UseStaticFiles, UseSpa and UseSpaStaticFiles extensions that do all the heavy lifting. Though, the difference between them isn’t all-clear from the official docs.
A brief summary would be:
UseSpa
serves the default page (index.html
) and redirects all requests there.UseStaticFiles
serves other thanindex.html
static files under the web root folder (wwwroot
, by default). Without this method, Kestrel would returnindex.html
in response to all static content requestsUseSpaStaticFiles
does a similar thing, but it requires ISpaStaticFileProvider to be registered to resolve the location of the static files. You need it if the static files are NOT under the defaultwwwroot
folder.
For simplicity, let’s host SPA from wwwroot
and leverage UseSpa
and UseStaticFiles
methods to set up caching and inject environment variables into an already built front-end bundle.
2.1. Caching
SPA caching rules boil down to
- Cache static assets (JavaScript files, images, etc.) forever, as those files have unique names generated on each build (thanks to WebPack).
- Prevent caching the
index.html
as it’s usually a tiny file keeping all the references to other static assets with unique names. This way, the client would always download a new bundle of JS & CSS files on releasing a new version.
Nowadays, the minimum set of HTTP response headers to disable caching requires:
Cache-Control: no-cache, no-store, must-revalidate, max-age=0
If you need to support old browsers then check out this debate on StackOverflow to adjust the rules. The key evolution points were:
Cache-Control
supersedesPragma
header (Mozilla docs);max-age=0
overtakesExpires: 0
(Mozilla docs).
As index.html
is hosted by UseSpa
method, so disabling cache happens this way:
app.UseSpa(c => c.Options.DefaultPageStaticFileOptions = new StaticFileOptions
{
OnPrepareResponse = ctx =>
{
var headers = ctx.Context.Response.GetTypedHeaders();
headers.CacheControl = new CacheControlHeaderValue
{
NoCache = true,
NoStore = true,
MustRevalidate = true,
MaxAge = TimeSpan.Zero
};
}
})
Then to force caching other static assets, we need to add this header to the HTTP response:
Cache-Control: max-age=31104000
where max-age
is set to an arbitrarily large number (e.g. a year, as per the example above).
Remember that hosting of non-index.html
files is controlled by UseStaticFiles
method, so the code would look like this:
app.UseStaticFiles(new StaticFileOptions
{
OnPrepareResponse = ctx =>
{
var headers = ctx.Context.Response.GetTypedHeaders();
headers.CacheControl = new CacheControlHeaderValue { MaxAge = TimeSpan.FromDays(12*30) };
}
})
Caching is sorted now.
2.2. Passing parameters to immutable front-end bundle
As pointed out above, preserving a well-tested and reliable front-end bundle is worth the effort. Then how hosting the front-end from the .NET may help inject environment variables? Of course, if we still hate doing a round trip to fetch variables in a JSON
file when the SPA has been bootstrapped.
Option 0. Injecting during deployment (preferable)
It would be the preferable option, but the implementation depends on the deployment tools. Some (e.g. Octopus Deployment) can replace variables in index.html
and minimised JavaScript files.
Strictly speaking, injecting variables during deployment would break the immutability of the bundle. Just a little bit, all the JS/CSS/etc. files remain untouched. But I’d turn a blind eye to it.
Option 1. Transforming the ‘index.html’ served by .NET
I’m not a fan of run-time index.html
manipulation in .NET, so feel free to jump over to ”Option 2” below.
However, we can define the environment variables in a JavaScript block in the index.html
. There are multiple ways of doing it, e.g.
<script type='text/javascript'>
window.importantVariable = '{#importantVariable}';
</script>
Then the index.html
is served by .NET where the {#importantVariable}
marker gets transferred to the environment value (e.g. taken from appsettings.json
).
The most common implementation of modifying the index.html
in .NET is adding a fallback MVC controller that would read and modify the file:
public class FallbackController : Controller
{
public async Task<IActionResult> Index()
{
string content = string.Empty;
using (var indexHtmlReader = System.IO.File.OpenText(_indexHtmlPath))
{ // Reads 'index.html'
content = await indexHtmlReader.ReadToEndAsync();
}
return new ContentResult
{
Content = content.Replace("{#importantVariable}", "Value"), // Replaces the variable
ContentType = "text/html"
};
}
}
and configuring the fallback in Startup.cs
:
app.UseWhen(context => !context.Request.Path.Value.StartsWith("/api"),
builder => {
app.UseSpa(c => {...});
app.UseMvc(routes => {
routes.MapSpaFallbackRoute(
name: "spa-fallback",
defaults: new { controller = "Fallback", action = "Index" });
});
});
Alternatively, you could do it all right in UseSpa
method and write a modified index.html
into the output… But let’s jump to a more exciting solution.
Option 2. Return ‘index.html’ along with a short-lived cookie (hacky way)
A less invasive solution would be returning a short-lived cookie along with index.html
that gets read when the SPA application starts.
Setting a short expiration period is a neat addition, as cookies get sent to the back-end on each XHR request. The expiration period (e.g. 30 sec) should be big enough to bootstrap and kick off the SPA to read the cookie and small enough to prevent adding an unnecessary cookie to most of the server-side requests.
Playing with the expiration period does introduce some fragility, negligible but still. I’ve warned you it’s a hacky way :)
Just add 1 line to the above UseSpa
:
app.UseSpa(c => c.Options.DefaultPageStaticFileOptions = new StaticFileOptions
{
OnPrepareResponse = ctx =>
{
var response = ctx.Context.Response;
response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue
{
NoCache = true,
NoStore = true,
MustRevalidate = true,
MaxAge = TimeSpan.Zero
};
// Here is our cookie:
response.Cookies.Append("importantVariable", "Value", new CookieOptions { MaxAge = TimeSpan.FromSeconds(30) });
}
})
Then reading the cookie in SPA on starting the app:
this.getCookie('importantVariable', 'default value');
private getCookie(name: string, defaultValue: string): string {
const value = ('; ' + document.cookie).split(`; ${name}=`).pop()?.split(';').shift();
return !!value ? decodeURIComponent(value) : defaultValue;
}
Note that cookies do not provide isolation by port. Not a biggie, but can cause raised eyebrows when in the local environment devs swap between running the front-end under Kestrel and the framework’s CLI / NodeJs.
Cool or not? Have your say in the comments below, on Reddit thread, Twitter or LinkedIn.
And check out my open source project on GitHub:
- YABT that does the trick with hosting SPA and passing parameters in cookies (direct link to Startup configuration and reading cookies in Angular).
- ServingSpaFromKestrel – WebAPI projects (with classic and minimal API) servicing a SPA front-end along with the API and Swagger/OpenAPI interface, and managing cache settings of the front-end assets.