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.