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
fromParkSquare.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 serveindex.html
files fromwwwroot
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 anysitemap.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.
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.
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!
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.