Static website with Umbraco Heartcore

Heads Up!

This article is several years old now, and much has happened since then, so please keep that in mind while reading it.

Stazor (Static + Razor  = Stazor /steɪzə/) pages is a concept which has been gnawing in my mind for quite some time with the advent and proliferation of JAMstack. JAMstack stands for JavaScript, APIs, and Markup. It’s a modern web development architecture based on client-side JavaScript, reusable APIs, and prebuilt Markup.

JAMstack brings with it speed, stability, scalability, security, serviceability and simplicity. Adam Griffith, Luminary's Managing Director, has written an article on the benefits of the JAMstack which is a good read if you need more information.

Speedy Gonzales

Speedy Gonzales from Warner Brothers

The speed and stability of JAMstack comes from the static prebuilt markup which is stored on the server.

An easy way to describe this is as follows:

Request flow for static vs dynamic pages

With the razor file, the webserver needs to access a database or an API or even a cache store to get the Title. In the illustration above, the razor file relies on the Controller to communicate with Umbraco Heartcore and retrieve content. This requires the webserver to work a bit harder to render the content.

With a static file, the webserver just needs to serve index.html. No extra work is needed to generate the content as it is already in the markup.

Generating static files

I feel we have come the full circle with developing websites. Back in college, I used Dreamweaver to create and update static HTML files. Then FTP it to a shared webserver. Rinse and repeat.

The problem with the above workflow is that my customer could not edit any content on their own accord and had to rely on a developer (or school-kid in my instance). So content management systems such as Umbraco CMS came into being. While a CMS allows editors to do what they want, it added in a few extra requirements such as executable code and databases.

With JAMstack, the idea was to get web servers to serve static pages rather than execute code. So platforms such as Gatsby Cloud and Netlify are utilized to pull in content from a CMS, generate a static HTML file and push it to a web server or to a storage resource.

Heartcore and JAMstack

While a traditional CMS could be plugged into generating static pages, a headless CMS is best suited to do this. Its RESTful API architecture, Image delivery service, Content Delivery Network (CDN), Webhooks and GraphQL API makes Heartcore an ideal fit for building a static website.

A static website generator such as Gatsby or Nuxt could be used to pull in content from Heartcore and generate static HTML files. Mix in the GraphQL API and GraphQL filtering capabilities of Heartcore to the recipe. Voila! You have got a JAMstack-ready tech-stack rearing to go!

Sticky JAM

JAMstack works beautifully when it does. But as in any scenario, there would be tradeoffs. With static pages, you get speed, stability, scalability, security, serviceability and simplicity. But on the negatives, you lose the ability for real-time updates, quick previews, and custom functionality such as forms/workflows. You also rely heavily on third party systems to do most of the heavy lifting for generating static pages.

What happens when a single update to a content item triggers the need for a thousand pages to be generated in one-go? Or when your build minutes are exhausted? These are real-world scenarios we have encountered. While there are indisputable benefits of using JAMstack, I wanted a way to work with razor views and avoid some of these pitfalls which JAMstack introduces.

Also, what if you are a die-hard .NET fan? What if you want to build a website with .NET 5.0 but still want the benefits of a static site? What if you are more comfortable writing C# and Razor markup than Javascript and Handlebars?

Stazor Pages

This is where the concept of Stazor pages (or Static Razor pages) come to the rescue. In it’s simplest form, Stazor pages generate static HTML files on the first request and deletes the same file on a webhook call.

The server would still need to execute some code rather than only serve static pages. Let’s see how this works.

Stazor Pages Middleware

Stazor Pages Middleware has three distinctive actions it takes in response to page requests and webhook calls.

Stazor Pages Lifecycle

First Request

When receiving the first request for a given URL, the content will be retrieved from Umbraco Heartcore, passed to an MVC Controller and will be rendered out as a page. The usual ASP.NET MVC concept of views, shared layouts, _ViewImports, _ViewStart, ViewComponents, DisplayTemplates all work without any modification. In short, if you have written an ASP.NET MVC Web Application, Stazor Pages Middleware can be easily plugged in with very little modification.

Once the page is rendered, the middleware saves the page as an HTML file to `AppData\Pages` folder. This location is used by the StaticFileMiddleware to serve the static file on subsequent requests. 

Subsequent Requests

On subsequent requests to the same URL, the Stazor Pages Middleware uses the ASP.NET StaticFileMiddleware to serve the previously generated static HTML file. No calls to Heartcore, no processing in Controllers. Just serving some static HTML goodness.

Webhook Call

Whenever content is updated, we have setup Heartcore to send a webhook request to our web application. Once again the Stazor Middleware takes over and deletes the previously generated content.

Stazor Page Lifecycle

This diagram outlines those three distinct actions which make up the Stazor Page Lifecycle.

Stazor Pages with Heartcore

Lord Lamington in Action

As with all things new, shiny and performant, Lord Lamington (our world-famous fictional client) wants to get in on the action. Lord Lamington wants to rebuild his website with Stazor Pages on .NET 5. He is also very pleased that he can use most of the Controllers and Views from his previous .NET Core 3.1 web application.

This is the boring part where I show the developers how we coded this new web application. Feel free to skip to where I show you a Stazor Pages sample site.

New ASP.NET Core Web Application Project

The code I explain below is available on GitHub. Feel free to clone the repo and make your own changes.

First off, make sure you have got Visual Studio 2019 version 16.8.2 or higher. Then create a new ASP.NET Core Web Application (Model-View-Controller) project with ASP.NET Core 5.0. I have called mine TwentyFourDaysIn.StazorPages.Heartcore. Add in references to the following NuGet packages using the Package Manager.

Cross-Platform Bonus

Running your code on macOS or Ubuntu? No Problem! Your cloned repo could run using Visual Studio Code or the .NET Core CLI. More instructions can be found in the README file on the repo. 

Add in your configuration

Modify your appsetting.config to add configuration to point to your Umbraco Heartcore project. For this example, feel free to use my personal Heartcore project to start off.

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  },
  "AllowedHosts": "*",
  "UmbracoConfig": {
    "ProjectAlias": "damazingnut",
    "ApiKey": ""
  }
}

Appsettings for Umbraco Heartcore

Content Structure on my Umbraco Heartcore instance

The following screen-grab will give you an idea of how I have structured the content in my Heartcore instance.

Content Tree

The Cafe Model

In this article, I will go through the steps to render out a static page which renders a cafe location. In order to do that, we need a model for Cafe which is derived from HeartcorePage and implements the IRetrievedContent interface. The class also needs to be decorated with the ModelBinder attribute with the type of StazorPages.Routing.RouteDataModelBinder. The MapToType() method also uses StazorPages.Heartcore.Models.HeartcorePage.MapCommonProperties() to map fields such as Id, Url, Name and IsVisible.

using Microsoft.AspNetCore.Mvc;
using StazorPages.Heartcore.Extensions;
using StazorPages.Heartcore.Models;
using StazorPages.Models;
using StazorPages.Routing;
using Umbraco.Headless.Client.Net.Delivery.Models;

namespace TwentyFourDaysIn.StazorPages.Heartcore.Models
{
    [ModelBinder(typeof(RouteDataModelBinder))]
    public class Cafe : HeartcorePage, IRetrievedContent
    {
        public const string ContentTypeAlias = "cafe";

        public string BuildingNumber { get; set; }

        public string AddressLine1 { get; set; }

        public string AddressLine2 { get; set; }

        public string Suburb { get; set; }

        public string Postcode { get; set; }

        public string State { get; set; }

        public string Title { get; set; }

        public string GetContentTypeAlias() => ContentTypeAlias;

        public static Cafe MapToType(Content content)
        {
            var model = new Cafe
            {
                Title = content.Name,
                BuildingNumber = content.Value<string>("buildingNumber"),
                AddressLine1 = content.Value<string>("addressLine1"),
                AddressLine2 = content.Value<string>("addressLine2"),
                Suburb = content.Value<string>("suburb"),
                Postcode = content.Value<string>("postcode"),
                State = content.Value<string>("state")
            };
            model.MapCommonProperties(content);
            return model;
        }
    }
}

The Cafe Model, derived from HeartcorePage and implementing IRetrievedContent

Cafe Document Type

The model above corresponds with this document type on my Heartcore instance.

Document Type for Cafe

The Cafe Controller

We are going to add a controller to render out a cafe page for us. Nothing special at all here.

using Microsoft.AspNetCore.Mvc;
using TwentyFourDaysIn.StazorPages.Heartcore.Models;

namespace TwentyFourDaysIn.StazorPages.Heartcore.Controllers
{
    public class CafeController : Controller
    {
        public IActionResult Index(Cafe model)
        {
            return View(model);
        }
    }
}

Cafe Controller - Get your Lamingtons here

The View

A view file for the cafe uses a simple bootstrap layout. Again, no special markup or skulduggery here.

@model Cafe

@{
    Layout = "_SimpleLayout";
}
<div class="container">
    <div class="jumbotron mt-3">
        <h3>A little bit of indulgence at our cafe in</h3>
        <h1>@Model.Title</h1>
        <p class="lead">@Model.BuildingNumber @Model.AddressLine1 @Model.AddressLine2</p>
        <p class="lead">@Model.Suburb @Model.State @Model.Postcode</p>
        <a class="btn btn-lg btn-primary" href="@Model.Url" role="button">URL to self</a>
    </div>
</div>

View for the Cafe

The _SimpleLayout.cshtml file can be found here.

TypeResolver

An implementation of ITypeResolver is needed so that the StazorPages Middleware could map a ContentTypeAlias to a strongly typed model. A simple implementation is given below.

using System;
using StazorPages.Heartcore.Resolvers;
using StazorPages.Models;
using TwentyFourDaysIn.StazorPages.Heartcore.Models;
using Umbraco.Headless.Client.Net.Delivery.Models;

namespace TwentyFourDaysIn.StazorPages.Heartcore.Resolvers
{
    public class TypeResolver : ITypeResolver
    {
        public IRetrievedContent GetTypedContent(Content content)
        {
            return content.ContentTypeAlias switch
            {
                Cafe.ContentTypeAlias => Cafe.MapToType(content),
                // truncated for brevity
                _ => throw new TypeLoadException($"Unknown type {content.ContentTypeAlias} encountered.")
            };
        }
    }
}

TypeResolver for Cafe ContentTypeAlias

Wire up the Startup file

In ConfigureServices() make a call to services.AddStazorPagesHeartcore() and in Configure(), call app.UseStazorPages(env). You also need to map an endpoint for StazorPages using endpoints.MapStazorPages(). The full Startup.cs file is given below for your reference.

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using StazorPages.Heartcore.Config;
using StazorPages.Heartcore.Extensions;
using StazorPages.Routing;
using TwentyFourDaysIn.StazorPages.Heartcore.Resolvers;

namespace TwentyFourDaysIn.StazorPages.Heartcore
{
    public class Startup
    {
        private readonly IWebHostEnvironment _webHostEnvironment;

        public Startup(IConfiguration configuration, IWebHostEnvironment env)
        {
            Configuration = configuration;
            _webHostEnvironment = env;
        }

        public IConfiguration Configuration { get; }

        public void ConfigureServices(IServiceCollection services)
        {
            services.AddControllersWithViews();

            var umbracoConfig = new UmbracoConfig();
            Configuration.GetSection(nameof(UmbracoConfig)).Bind(umbracoConfig);

            services.AddStazorPagesHeartcore(_webHostEnvironment, umbracoConfig, new TypeResolver());
        }

        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }
            else
            {
                app.UseExceptionHandler("/Home/Error");
                app.UseHsts();
            }

            app.UseHttpsRedirection();
            app.UseStaticFiles();

            app.UseRouting();

            app.UseAuthorization();

            app.UseStazorPages(env);

            app.UseEndpoints(endpoints =>
            {
                endpoints.MapControllers();
                endpoints.MapStazorPages();
            });
        }
    }
}

Wireup Startup.cs to use StazorPages

Heartcore Content

The content for the Melbourne CBD cafe on my Heartcore instance is filled in like this.

Melbourne CBD Cafe data

If you visit https://localhost:44340/cafes/melbourne-cbd on your development machine, you should get this simple page with data retrieved from Umbraco Heartcore.

Webpage with Melbourne Cafe details

 Voila! Static File

On the first request to the URL, content is retrieved from Heartcore and then saved to this static HTML file.

<!doctype html>
<html lang="en">
<head>
    <!-- Required meta tags -->
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">

    <!-- Bootstrap CSS -->
    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css" integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossorigin="anonymous">

    <title>24 Days In Umbraco - Melbourne CBD</title>
</head>
<body>

<div class="container">
    <div class="jumbotron mt-3">
        <h3>A little bit of indulgence at our cafe in</h3>
        <h1>Melbourne CBD</h1>
        <p class="lead">Level 1 195 Little Collins Street </p>
        <p class="lead">Melbourne VIC 3000</p>
        <a class="btn btn-lg btn-primary" href="/cafes/melbourne-cbd/" role="button">URL to self</a>
    </div>
</div>
    <div class="container">
        <small>Stazor Page generated at 29-Nov-2020 09:20:21 UTC</small>
    </div>
</body>
</html>

The final HTML output saved by StazorPages Middleware

Any subsequent requests to the same cafe page would result in this static file been served.

Pro Dev Tip

Want to debug some tricky code? You can turn off the generation of static files on your development environment by amending the app.UseStazorPages(env) in the Startup.cs file to app.UseStazorPages(env, !env.IsDevelopment()).

Content Update process

Creating a webhook in Umbraco Heartcore and pointing to the `/api/contentupdate` endpoint deletes the page whenever a Publish content event occurs. This endpoint is defined in the StazorPages.Heartcore library. Essentially, Heartcore webhook calls are handled automagically. 💫

Umbraco Heartcore Webhook Configuration

Similarly, other events such as Unpublish content, Delete content should be configured to the same endpoint.

Sample Stazor Pages Site

Lord Lamington has got a StazorPages.Heartcore sample site at https://lordlamingtonheartcore.azurewebsites.net/.

Lord Lamington's website

You can see a screengrab below which highlights the generated time of the static file. For example, you might see Stazor Page generated at 25-Nov-2020 01:12:03 UTC

Generated time of Stazor Page

Speedy Gonzales Again

Speedy Gonzales from Warner Brothers

Time to check some metrics regarding speed. I don't claim this to be a proper benchmark, it's anecdotal.

I tested loading https://lordlamingtonheartcore.azurewebsites.net/cafes/ on my local machine running IIS Express with the following setup. Each time, I restarted the application and loaded the home page prior to loading the cafes page using Postman. All durations are in milliseconds.

  First Run Second Run Third Run
Stazor Pages disabled 99 465 870
First request with Stazor Pages 179 2374 254
Second request with Stazor Pages 65 78 86
Speedy's verdict - Overall, the first request with Stazor Pages enabled was a bit slower than with Stazor Pages disabled. But with the second and subsequent requests, hands down, Stazor Pages wins on speed.

In its infancy

At the time of writing this article, Stazor Pages is in its infancy. There are many improvements which are needed both to the codebase and to the routing mechanism. As I plan on dogfooding this for my wife's business site, follow me on @dAmazingNut for future updates.

Open Source Code

With the disclaimer above, the source code is shared on GitHub. It is open for Pull Requests and Suggestions.

Happy Servers and Happier Developers

“In a nutshell, Heartcore means: happy marketers who reduce their time to market, happy editors who create content early-on, and happy developers who get to use the latest tech.”

Okay! So that's my own quote. But now Heartcore + Stazor Pages allow for Happy servers. Servers which do not need to work as hard. They just need to work hard once and serve static content on subsequent requests.

Developers who are happy with Heartcore, now have more reasons to be happier. They (I, nerd 🤓) get to use ASP.NET Core 5 and still build a static website for optimum performance.💥

Anyone for some Jam and Vanilla Lamingtons?

Emmanuel Tissera

Emmanuel is on Twitter as