Introducing Razor SSG

Introducing Razor SSG Background
14 min read

Razor SSG is a Razor Pages powered Markdown alternative to Ruby's Jekyll & Next.js that's ideal for generating static websites & blogs using C#, Razor Pages & Markdown.

GitHub Codespaces Friendly

In addition to having a pure Razor + .NET solution to create fast, CDN-hostable static websites, it also aims to provide a great experience from GitHub Codespaces, where you can create, modify, preview & check-in changes before the included GitHub Actions auto deploy changes to its GitHub Pages CDN - all from your iPad!

To see this in action, we walk through the entire workflow of creating, updating and adding features to a custom Razor SSG website from just a browser using Codespaces, that auto publishes changes to your GitHub Repo's gh-pages branch where it's hosted for free on GitHub Pages CDN:

Enhance with simple, modern JavaScript

For enhanced interactivity, static markdown content can be progressively enhanced with Vue 3 components, as done in this example that embed's the GettingStarted.mjs Vue Component to create new Razor SSG App's below with:

<getting-started template="razor-ssg"></getting-started>

Although with full control over the websites _Layout.cshtml, you're free to use any preferred JS Module or Web Component you prefer.

Razor Pages

Your website can be built using either Markdown .md or Razor .cshtml pages, although it's generally recommended to use Markdown to capture the static content for your website for improved productivity and ease of maintenance.

Content in Markdown, Functionality in Razor Pages

The basic premise behind most built-in features is to capture static content in markdown using a combination of folder structure & file name conventions in addition to each markdown page's frontmatter & content. This information is then used to power each feature using Razor pages for precise layout and functionality.

The template includes the source code for each website feature, enabling full customization that also serves as good examples for how to implement your own custom markdown-powered website features.

Markdown Feature Structure

All markdown features are effectively implemented in the same way, starting with a _folder for maintaining its static markdown content, a .cs class to load the markdown and a .cshtml Razor Page to render it:

Location Description
/_{Feature} Maintains the static markdown for the feature
Markdown.{Feature}.cs Functionality to read the feature's markdown into logical collections
{Feature}.cshtml Functionality to Render the feature
Configure.Ssg.cs Initializes and registers the feature with ASP .NET's IOC

Lets see what this looks like in practice by walking through the "Pages" feature:

Pages Feature

The pages feature simply makes all pages in the _pages folder, available from /{filename}.

Where the included pages:

/_pages

  • privacy.md
  • speaking.md
  • uses.md

Are made available from:

Loading Pages Markdown

The code that loads the Pages feature markdown content is in Markdown.Pages.cs:

public class MarkdownPages : MarkdownPagesBase<MarkdownFileInfo>
{
    public MarkdownPages(ILogger<MarkdownPages> log, IWebHostEnvironment env) 
        : base(log,env) {}
    
    List<MarkdownFileInfo> Pages { get; set; } = new();
    public List<MarkdownFileInfo> VisiblePages => Pages.Where(IsVisible).ToList();

    public MarkdownFileInfo? GetBySlug(string slug) => 
        Fresh(VisiblePages.FirstOrDefault(x => x.Slug == slug));

    public void LoadFrom(string fromDirectory)
    {
        Pages.Clear();
        var fs = AssertVirtualFiles();
        var files = fs.GetDirectory(fromDirectory).GetAllFiles().ToList();
        var log = LogManager.GetLogger(GetType());
        log.InfoFormat("Found {0} pages", files.Count);

        var pipeline = CreatePipeline();

        foreach (var file in files)
        {
            var doc = Load(file.VirtualPath, pipeline);
            if (doc == null)
                continue;

            Pages.Add(doc);
        }
    }
}

Which ultimately just loads Markdown files using the configured Markdig pipeline in its Pages collection which is made available via its VisiblePages property which returns all documents in development whilst hiding Draft and content published at a Future Date from production builds.

Rendering Markdown Pages

The pages are then rendered in Page.cshtml Razor Page that's available from /{slug}

@page "/{slug}"
@model MyApp.Page
@inject MarkdownPages Markdown

@implements IRenderStatic<MyApp.Page>
@functions {
    public List<Page> GetStaticProps(RenderContext ctx)
    {
        var markdown = ctx.Resolve<MarkdownPages>();
        return markdown.VisiblePages.Map(page => new Page { Slug = page.Slug! });
    }
}

@{
    var doc = Markdown.GetBySlug(Model.Slug);
    if (doc.Layout != null) 
        Layout = doc.Layout == "none"
            ? null
            : doc.Layout;
    ViewData["Title"] = doc.Title;
}

<link rel="stylesheet" href="css/typography.css">
<section class="flex-col md:flex-row flex justify-center mt-16 mb-16 md:mb-12">
    <h1 class="text-4xl tracking-tight font-extrabold text-gray-900">
        @doc.Title
    </h1>
</section>    
<div class="mx-auto">
    <div class="mx-auto prose lg:prose-xl mb-24">
        @Html.Raw(doc.Preview)
    </div>
</div>

@await Html.PartialAsync("HighlightIncludes")
<script>hljs.highlightAll()</script>

Which uses a custom layout if one is defined in its frontmatter which speaking.md utilizes in its layout frontmatter:

---
title: Speaking
layout: _LayoutContent
---

To render the page using _LayoutContent.cshtml visible by the background backdrop in its /speaking page.

What's New Feature

The /whatsnew page is an example of creating a custom Markdown feature to implement a portfolio or a product releases page where a new folder is created per release, containing both release date and release or project name, with all features in that release maintained markdown content sorted in alphabetical order:

/_whatsnew

  • /2023-03-08_Animaginary
    • feature1.md
  • /2023-03-18_OpenShuttle
    • feature1.md
  • /2023-03-28_Planetaria
    • feature1.md

What's New follows the same structure as Pages feature which is loaded in:

and rendered in:

Blog Feature

The blog maintains its markdown posts in a flat folder which each Markdown post containing its publish date and URL slug it should be published under:

/_posts

  • ...
  • 2023-01-21_start.md
  • 2023-03-21_javascript.md
  • 2023-03-28_razor-ssg.md

As the Blog has more features it requires a larger Markdown.Blog.cs to load its Markdown posts that is rendered in several different Razor Pages for each of its Views:

Page Description Example
Blog.cshtml Main Blog layout /blog
Posts/Index.cshtml Navigable Archive grid of Posts /posts
Posts/Post.cshtml Individual Blog Post (like this!) /posts/razor-ssg
Author.cshtml Display Posts by Author /posts/author/lucy-bates
Tagged.cshtml Display Posts by Tag /posts/tagged/markdown
Year.cshtml Display Posts by Year /posts/year/2023

General Features

Most unique markdown features are captured in their Markdown's frontmatter metadata, but in general these features are broadly available for all features:

  • Live Reload - Latest Markdown content is displayed during Development
  • Custom Layouts - Render post in custom Razor Layout with layout: _LayoutAlt
  • Drafts - Prevent posts being worked on from being published with draft: true
  • Future Dates - Posts with a future date wont be published until that date

Initializing and Loading Markdown Features

All markdown features are initialized in the same way in Configure.Ssg.cs where they're registered in ASP.NET Core's IOC and initialized after the App's plugins are loaded by injecting with the App's Virtual Files provider before using it to read from the directory where the markdown content for each feature is maintained:

public class ConfigureSsg : IHostingStartup
{
    public void Configure(IWebHostBuilder builder) => builder
        .ConfigureServices(services =>
        {
            services.AddSingleton<RazorPagesEngine>();
            services.AddSingleton<MarkdownPages>();
            services.AddSingleton<MarkdownWhatsNew>();
            services.AddSingleton<MarkdownBlog>();
        })
        .ConfigureAppHost(afterPluginsLoaded: appHost => {
            var pages = appHost.Resolve<MarkdownPages>();
            var whatsNew = appHost.Resolve<MarkdownWhatsNew>();
            var blogPosts = appHost.Resolve<MarkdownBlog>();
            
            var features = new IMarkdownPages[] { pages, whatsNew, blogPosts }; 
            features.Each(x => x.VirtualFiles = appHost.VirtualFiles);

            // Custom initialization
            blogPosts.Authors = Authors;

            // Load feature markdown content
            pages.LoadFrom("_pages");
            whatsNew.LoadFrom("_whatsnew");
            blogPosts.LoadFrom("_posts");
        });
    });
    //...
}

These dependencies are then injected in the feature's Razor Pages to query and render the loaded markdown content.

Custom Frontmatter

You can extend the MarkdownFileInfo type used to maintain the markdown content and metadata of each loaded Markdown file by adding any additional metadata you want included as C# properties on:

// Add additional frontmatter info to include
public class MarkdownFileInfo : MarkdownFileBase
{
}

Any additional properties are automatically populated using ServiceStack's built-in Automapping which includes rich support for converting string frontmatter values into native .NET types.

Updating to latest versions

You can easily update all the JavaScript dependencies used in postinstall.js by running:

node postinstall.js

This will also update the Markdown features *.cs implementations which is delivered as source files instead of an external NuGet package to enable full customization, easier debugging whilst supporting easy upgrades.

If you do customize any of the .cs files, you'll want to exclude them from being updated by removing them from:

const hostFiles = [
    'Markdown.Blog.cs',
    'Markdown.Pages.cs',
    'Markdown.WhatsNew.cs',
    'MarkdownPagesBase.cs',
]

Markdown Tag Helper

The included MarkdownTagHelper.cs can be used in hybrid Razor Pages like About.cshtml to render the /about page which requires the flexibility of Razor Pages with a static content component which you prefer to maintain inline with Markdown.

The <markdown /> tag helper renders plain HTML, which you can apply Tailwind's @typography styles by including typography.css and annotating it with your preferred prose variant, e.g:

<link rel="stylesheet" href="css/typography.css">
<markdown class="prose">
  Markdown content...
</markdown>

Static Static Generation (SSG)

All features up till now describes how this template implements a Markdown powered Razor Pages .NET application, where this template differs in its published output, where instead of a .NET App deployed to a VM or App server it generates static *.html files that's bundled together with /wwwroot static assets in the /dist folder that can be previewed by launching a HTTP Server from that folder with the built-in npm script:

npm run serve

To run npx http-server on http://localhost:8080 that you can open in a browser to preview the published version of your site as it would be when hosted on a CDN.

Static Razor Pages

The static generation functionality works by scanning all your Razor Pages and prerendering the pages with prerendering instructions.

Pages with Static Routes

Pages with static routes can be marked to be prerendered by annotating it with the [RenderStatic] attribute as done in About.cshtml:

@page "/about"
@attribute [RenderStatic]

Which saves the pre-rendered page using the pages route with a .html suffix, e.g: /{@page route}.html whilst pages with static routes with a trailing / are saved to /{@page route}/index.html as done for Posts/Index.cshtml:

@page "/posts/"
@attribute [RenderStatic]

Explicit generated paths

To keep the generated pages in-sync with using the same routes as your Razor Pages in development it's recommended to use the implied rendered paths, but if preferred you can specify which path the page should be rendered to instead with:

@page "/posts/"
@attribute [RenderStatic("/posts/index.html")]

Pages with Dynamic Routes

Prerendering dynamic pages follows Next.js getStaticProps convention which you can implement using IRenderStatic<PageModel> by returning a Page Model for each page that should be generated as done in Posts/Post.cshtml and Page.cshtml:

@page "/{slug}"
@model MyApp.Page

@implements IRenderStatic<MyApp.Page>
@functions {
    public List<Page> GetStaticProps(RenderContext ctx)
    {
        var markdown = ctx.Resolve<MarkdownPages>();
        return markdown.VisiblePages.Map(page => new Page { Slug = page.Slug! });
    }
}
...

In this case it returns a Page Model for every Visible markdown page in /_pages that ends up rendering the following pages in /dist:

  • /privacy.html
  • /speaking.html
  • /uses.html

Limitations

The primary limitations for developing statically generated Apps is that a snapshot of entire App is generated at deployment, which prohibits being able to render different content per request, e.g. for Authenticated users which would require executing custom JavaScript after the page loads to dynamically alter the page's initial content.

Otherwise in practice you'll be able develop your Razor Pages utilizing Razor's full feature-set, the primary concessions stem from Pages being executed in a static context which prohibits pages from returning dynamic content per request, instead any "different views" should be maintained in separate pages.

No QueryString Params

As the generated pages should adopt the same routes as your Razor Pages you'll need to avoid relying on ?QueryString params and instead capture all required parameters for a page in its @page route as done for:

Posts/Author.cshtml

@page "/posts/author/{slug}"
@model AuthorModel
@inject MarkdownBlog Blog

@implements IRenderStatic<AuthorModel>
@functions {
    public List<AuthorModel> GetStaticProps(RenderContext ctx) => ctx.Resolve<MarkdownBlog>()
        .AuthorSlugMap.Keys.Map(x => new AuthorModel { Slug = x });
}
...

Which lists all posts by an Author, e.g: /posts/author/lucy-bates, likewise required for:

Posts/Tagged.cshtml

@page "/posts/tagged/{slug}"
@model TaggedModel
@inject MarkdownBlog Blog

@implements IRenderStatic<TaggedModel>
@functions {
    public List<TaggedModel> GetStaticProps(RenderContext ctx) => ctx.Resolve<MarkdownBlog>()
        .TagSlugMap.Keys.Map(x => new TaggedModel { Slug = x });
}
...

Which lists all related posts with a specific tag, e.g: /posts/tagged/markdown, and for:

Posts/Year.cshtml

@page "/posts/year/{year}"
@model YearModel
@inject MarkdownBlog Blog

@implements IRenderStatic<YearModel>
@functions {
    public List<YearModel> GetStaticProps(RenderContext ctx) => ctx.Resolve<MarkdownBlog>()
        .VisiblePosts.Select(x => x.Date.GetValueOrDefault().Year)
            .Distinct().Map(x => new YearModel { Year = x });
}

...

Which lists all posts published in a specific year, e.g: /posts/year/2023.

Conceivably these "different views" could've been implemented by the same page with different ?author, ?tag and ?year QueryString params, but are instead extracted into different pages to support its statically generated *.html outputs.

Prerendering Task

The prerender AppTask that pre-renders the entire website is also registered in Configure.Ssg.cs:

  .ConfigureAppHost(afterAppHostInit: appHost =>
  {
      // prerender with: `$ npm run prerender` 
      AppTasks.Register("prerender", args =>
      {
          var distDir = appHost.ContentRootDirectory.RealPath.CombineWith("dist");
          if (Directory.Exists(distDir))
              FileSystemVirtualFiles.DeleteDirectory(distDir);
          FileSystemVirtualFiles.CopyAll(
              new DirectoryInfo(appHost.ContentRootDirectory.RealPath.CombineWith("wwwroot")),
              new DirectoryInfo(distDir));
          var razorFiles = appHost.VirtualFiles.GetAllMatchingFiles("*.cshtml");
          RazorSsg.PrerenderAsync(appHost, razorFiles, distDir).GetAwaiter().GetResult();
      });
  });
  //...

Which we can see:

  1. Deletes /dist folder
  2. Copies /wwwroot contents into /dist
  3. Passes all App's Razor *.cshtml files to RazorSsg to do the pre-rendering

Where it processes all pages with [RenderStatic] and IRenderStatic<PageModel> prerendering instructions to the specified /dist folder.

Previewing prerendered site

To preview your SSG website, run the prerendered task with:

npm run prerender

Which renders your site to /_dist which you can run a HTTP Server from with:

npm run serve

That you can preview with your browser at http://localhost:8080.

Publishing

The included build.yml GitHub Action takes care of running the prerendered task and deploying it to your Repo's GitHub Pages where it will be available at:

https://$org_name.github.io/$repo/

Alternatively you can use a Custom domain for GitHub Pages by registering a CNAME DNS entry for your preferred Custom Domain, e.g:

Record Type Value TTL
mydomain.org CNAME org_name.github.io 3600

That you can either configure in your Repo settings or if you prefer to maintain it with your code-base, save the domain name to /wwwroot/CNAME, e.g:

www.mydomain.org

Benefits after migrating from Jekyll

Whilst still only at v1 release, we found it already had a number of advantages over the existing Jekyll static website:

  • Faster live reloads
  • C#/Razor more type-save & productive than Ruby/Liquid
  • Greater flexibility in implementing new features
  • Better IDE support (from Rider)
  • Ability to reuse our .NET libraries
  • Better development experience

The last point ultimately prompted seeking an alternative solution as previously Jekyll was used from Windows/WSL which was awkward to manage from a different filesystem with Jekyll upgrades breaking RubyMine support forcing the use of text editors to maintain its code-base and content.

Used by the new servicestack.net

Deterred by the growing complexity of current SSG solutions, we decided to create a new solution using C#/Razor (our preferred technology for generating server HTML) with a clean implementation that allowed full control with an npm dependency-free solution letting us adopt our preferred approach to Simple, Modern JavaScript without any build-tooling or SPA complexity.

We're happy with the results of https://servicestack.net new Razor SSG website:

A clean, crisp code-base utilizing simple JS Module Vue 3 components, the source code of which is publicly maintained at:

Which serves as a good example at how well this template scales for larger websites.

Markdown Videos Feature

It only needed one new Markdown feature to display our growing video library:

Which you're free to reuse in your own websites needing a similar feature.

Feedback & Feature Requests Welcome

In future we'll look at expanding this template with generic Markdown features suitable for websites, blogs & portfolios, or maintain a shared community collection if there ends up being community contributions of Razor SSG & Markdown features.

In the meantime, we welcome any feedback or new feature requests at:

https://servicestack.net/ideas

Written by Lucy Bates
Works in software design, company building, and the aerospace industry.