I’m a big proponent of static site generators. They provide the ultimate combination of performance and deployment options, period. For ASP.NET developers, we have access to powerful application building tools like Razor and ASP.NET Core. Ultimately all these dynamic elements combine to generate HTML. What if we could combine the dynamic goodness of ASP.NET Core with the powerhouse performance of static sites?

Well, this is what this post is all about! We’ll see how we can use ASP.NET Core’s hosting environment to generate static pages from our dynamic content.

ASP.NET Core Prerequisites

When working with ASP.NET Core web applications, we’ll likely be dealing with a Razor-powered variant: ASP.NET Core MVC, Razor Pages, or Blazor. In this post, we’ll focus on the MVC and Razor Pages server-side technologies, but it might be possible to get this example to work with Blazor.

We’ll also need to ensure we install several middlewares as part of our ASP.NET Core pipeline:

  • DefaultFilesMiddleware
  • StaticFileMiddleware
  • RoutingMiddleware
  • SitemapMiddleware from ParkSquare.AspNetCore.Sitemap NuGet Package. (optional)

Let’s see what the Configure method looks like in a brand new ASP.NET Core MVC application after

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
    }
    else
    {
        app.UseExceptionHandler("/Home/Error");
        // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
        app.UseHsts();
    }

    app.UseHttpsRedirection();
    
    // important middlewares
    app.UseDefaultFiles();
    app.UseStaticFiles();
    app.UseRouting();
    app.UseSitemap(new SitemapOptions());
    // end of important middleware
    
    app.UseAuthorization();

    app.UseEndpoints(endpoints =>
    {
        endpoints.MapControllerRoute(
            name: "default",
            pattern: "{controller=Home}/{action=Index}/{id?}");
    });
}

Building a Static HTML Generator

A few ASP.NET Core MVC factors are working to our advantage that makes this solution possible:

  • The DefaultFileMiddleware will allow us to serve index.html files from wwwroot transparently.
  • The StaticFileMiddleware is registered before any endpoints, allowing our server host to serve static files before hitting our MVC controller actions.
  • The SitemapMiddleware gives us an idea of all the routes in our ASP.NET Core application, although we could generate any sitemap.xml files through other means or even write it ourselves.
  • ASP.NET Core generates links relatively, so links just work.

Let’s put all these elements together and create a solution!

The Static Site Solution

Within a new ASP.NET Core MVC solution, we’ll need to add two NuGet packages: Oakton.AspNetCore and ParkSquare.AspNetCore.Sitemap. Here is my csproj file for reference.

<Project Sdk="Microsoft.NET.Sdk.Web">

    <PropertyGroup>
        <TargetFramework>net5.0</TargetFramework>
    </PropertyGroup>

    <ItemGroup>
      <PackageReference Include="Oakton.AspNetCore" Version="3.0.0" />
      <PackageReference Include="ParkSquare.AspNetCore.Sitemap" Version="1.0.1" />
    </ItemGroup>

</Project>

Next, we’ll need to modify the Program file to allow for Oakton to find our command-line commands.

🔥 Related Content - Check out my previous posts with Oakton to learn more about the best command-line library in .NET today.
using System.Threading.Tasks;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Hosting;
using Oakton.AspNetCore;

[assembly:Oakton.OaktonCommandAssembly]

namespace StaticSiteTool
{
    public class Program
    {
        public static Task<int> Main(string[] args)
        {
            return CreateHostBuilder(args)
                .RunOaktonCommands(args);
        }

        public static IHostBuilder CreateHostBuilder(string[] args) =>
            Host.CreateDefaultBuilder(args)
                .ConfigureWebHostDefaults(webBuilder =>
                {
                    webBuilder.UseStartup<Startup>();
                });
    }
}

This updated Program class file will find all Oakton commands in our project and allow us to run either our web application or a specific command. Let’s write our StaticPagesCommand now.

using System;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using System.Xml;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Oakton;
using Oakton.AspNetCore;

namespace StaticSiteTool.Commands
{
    public class StaticPagesInput : NetCoreInput
    {
        [FlagAlias("url", 'u')]
        public string UrlFlag { get; set; }
            = "http://localhost:5000";

        [FlagAlias("sitemap", 's')]
        public string SitemapFlag { get; set; }
            = "sitemap.xml";
    }

    public class StaticPagesCommand : OaktonAsyncCommand<StaticPagesInput>
    {
        public override async Task<bool> Execute(StaticPagesInput input)
        {
            using var buildHost = input.BuildHost();

            var lifetime = buildHost
                .Services
                .GetRequiredService<IHostApplicationLifetime>();

            // process HTML files after the server 
            // has started up
            lifetime.ApplicationStarted.Register(async (state) =>
            {
                var host = (IHost) state;
                var webHostEnvironment = host
                    .Services
                    .GetRequiredService<IWebHostEnvironment>();

                var logger = host
                    .Services
                    .GetRequiredService<ILogger<StaticPagesCommand>>();

                logger.LogInformation($"Attempting to access {input.UrlFlag }.");

                var client = new HttpClient
                {
                    BaseAddress = new Uri(input.UrlFlag )
                };

                var siteMapResponse =
                    await client.GetAsync(input.SitemapFlag);

                var siteMap = await siteMapResponse.Content.ReadAsStringAsync();
                var xml = new XmlDocument();

                // load sitemap
                xml.LoadXml(siteMap);

                var locations = xml
                    .GetElementsByTagName("loc")
                    .Cast<XmlElement>();

                var wwwRoot = webHostEnvironment.WebRootPath;

                foreach (var location in locations)
                {
                    var uri = new Uri(location.InnerText);
                    var localPath = uri.LocalPath;

                    // write html to disk
                    if (Path.GetExtension(localPath) is "" or null)
                    {
                        localPath = Path.Combine(localPath, "index.html");
                    }

                    localPath = wwwRoot + localPath;

                    // delete the file so it doesn't
                    // get served instead of our endpoint
                    if (File.Exists(localPath))
                    {
                        File.Delete(localPath);
                    }

                    var page = await client.GetStringAsync(uri);
                    var directory = Directory.GetParent(localPath);

                    if (!directory.Exists)
                    {
                        directory.Create();
                    }

                    await File.WriteAllTextAsync(localPath, page);
                }
                
                await host.StopAsync();
                
            }, buildHost);

            await buildHost.RunAsync();

            return true;
        }
    }
}

This command will start our web host and start making requests to all the ASP.NET Core endpoints found in our sitemap.xml. We also want to delete any existing files, so the server doesn’t send static files on our command’s subsequent runs.

To run our command, we can use the following command-line arguments.

dotnet run staticpages

The tool will generate our static HTML pages, which we can see in the Solution explorer in this before and after image.

static file command run to generate static pages

The next time we run our application, we’ll see Kestrel serving the static HTML files from wwwroot. No server-side rendering or slow(er) dynamic code!

static file being served from ASP.NET Core

Interested parties can head over to my GitHub repository where there is a working solution.

Conclusion

ASP.NET Core is flexible enough to do some fantastic static site generation at build time from the command line. Combining this technique with a build system like GitHub Actions can also allow us to build and deploy an ASP.NET Core application to a static host like GitHub Pages. Mileage of this technique will vary on dependencies and configuration of our web application, but with ASP.NET Core, it’s never been more possible.

I hope you enjoyed this post, and as always, thank you for reading.