Recently, I had a need to host 2 Single Page Applications (SPA) from the same .NET backend using ASP.NET Core.
Typically, to host a SPA, we can use Microsoft.AspNetCore.SpaServices.Extensions and add the following code to Program.cs or Startup.cs:
// add required services to enable serving SPA static files from wwwroot
builder.Services.AddSpaStaticFiles(config => config.RootPath = "wwwroot/app");
/*...*/
// then, at the end of the pipeline, add the SPA middleware
app.UseSpaStaticFiles();
app.UseSpa(spa =>
{
spa.Options.DefaultPage = "/index.html";
spa.Options.DefaultPageStaticFileOptions = new()
{
RequestPath = "/app",
RedirectToAppendTrailingSlash = true,
};
});
The 2 SPAs I needed to serve had to live under different URL paths on the same domain.
First thing I attempted was to register 2 SPA middlewares:
builder.Services.AddSpaStaticFiles(config => config.RootPath = "wwwroot/app-1");
builder.Services.AddSpaStaticFiles(config => config.RootPath = "wwwroot/app-2");
/*...*/
// SPA #1
app.UseSpa(spa =>
{
spa.Options.DefaultPage = "/app-1/index.html";
spa.Options.DefaultPageStaticFileOptions = new()
{
RequestPath = "/app-1",
RedirectToAppendTrailingSlash = true,
};
};
// SPA #2
app.UseSpa(spa =>
{
spa.Options.DefaultPage = "/app-2/index.html";
spa.Options.DefaultPageStaticFileOptions = new()
{
RequestPath = "/app-2",
RedirectToAppendTrailingSlash = true,
};
};
This, however, did not work as I hoped.
AddSpaStaticFiles(...) registers the service as a singleton meaning I can only configure a single root path.
The SPA middleware will short-circuit and return a 404 if it can’t handle the incoming request. Basically, if a request is meant for app-2 it will terminate in the app-1 SPA middleware and return a 404 error. The app-1 SPA won’t call the next middleware in the pipeline. This is by design according to Microsoft documentation.
My next attempt was to use a single RootPath setting it to the parent folder wwwroot and use the branching capabilities of the ASP.NET Core pipeline. This is done using UseWhen(...).
builder.Services.AddSpaStaticFiles(config => config.RootPath = "wwwroot");
/*...*/
app.UseWhen(
ctx => ctx.Request.Path.StartsWithSegments("/app-1"),
subAppBuilder =>
{
subAppBuilder.UseSpaStaticFiles();
subAppBuilder.UseSpa(spa =>
{
spa.Options.DefaultPage = "/app-1/index.html";
spa.Options.DefaultPageStaticFileOptions = new()
{
RequestPath = "/app-1",
RedirectToAppendTrailingSlash = true,
};
});
}
);
app.UseWhen(
ctx => ctx.Request.Path.StartsWithSegments("/app-2"),
subAppBuilder =>
{
subAppBuilder.UseSpaStaticFiles();
subAppBuilder.UseSpa(spa =>
{
spa.Options.DefaultPage = "/app-2/index.html";
spa.Options.DefaultPageStaticFileOptions = new()
{
RequestPath = "/app-2",
RedirectToAppendTrailingSlash = true,
};
});
}
);
This didn’t fully work. The SPA middlewares kept complaining about app-1/index.html and app-2/index.html not being found.
So, this turned out to be a bit of a headache.
Luckily, the StaticFileOptions instance I’m passing to spa.Options.DefaultPageStaticFileOptions accepts a custom FileProvider instance to be configured and used.
.NET has a number of FileProviders out of the box (and you can build your own). I created a new instance of PhysicalFileProvider configured with the appropriate root path:
builder.Services.AddSpaStaticFiles(config => config.RootPath = "wwwroot");
/*...*/
app.UseWhen(
ctx => ctx.Request.Path.StartsWithSegments("/app-1"),
subAppBuilder =>
{
subAppBuilder.UseSpaStaticFiles();
subAppBuilder.UseSpa(spa =>
{
spa.Options.DefaultPage = "/app-1/index.html";
spa.Options.DefaultPageStaticFileOptions = new()
{
RequestPath = "/app-1",
RedirectToAppendTrailingSlash = true,
FileProvider = new PhysicalFileProvider(
Path.Combine(app.Environment.ContentRootPath, "wwwroot", "app-1")
),
};
});
}
);
app.UseWhen(
ctx => ctx.Request.Path.StartsWithSegments("/app-2"),
subAppBuilder =>
{
subAppBuilder.UseSpaStaticFiles();
subAppBuilder.UseSpa(spa =>
{
spa.Options.DefaultPage = "/app-2/index.html";
spa.Options.DefaultPageStaticFileOptions = new()
{
RequestPath = "/app-2",
RedirectToAppendTrailingSlash = true,
FileProvider = new PhysicalFileProvider(
Path.Combine(app.Environment.ContentRootPath, "wwwroot", "app-2")
};
});
}
);
This worked exactly as I wanted and both apps are now served properly from the backend.