Headless Preview for Umbraco

Headless Preview for Umbraco

Umbraco 15.2 was released yesterday. Among the changes are a bunch of fixes for the Delivery API, which arguably has been suffering a bit since the introduction of the new caching system in V15.

All of this means that I can finally release my Headless Preview package 🥳

A package for headless preview?

The Headless Preview package is an alternative to the built-in preview functionality of Umbraco.

But why do we need this? 🤔

Umbraco’s built-in preview functionality is built for previewing pages rendered by Umbraco. That is, Razor rendered pages. It expects Umbraco to control all page routing and rendering, including the rendering context (e.g. content variation).

This is just not the case for headless sites. Umbraco might control part of the page routing by means of the document tree structure… but even that is not a given thing.

The Headless Preview package aims to bridge this gap between the Umbraco documents and the headless page rendering. It is of course an open source package, and you can check it out in the package GitHup repo.

Putting it to the test

Preview is inherently difficult for headless sites. Various headless technologies employ their own means of enabling preview, and some don’t support it at all 🤷

In the following I’ll show you how the Headless Preview package can enable preview for the Next.js Umbraco blog example.

I have put all of this in a GitHub repo if you just want to try it out - or you can follow along below 😉

Step 1: Setup the Umbraco site

First thing’s first: An Umbraco site is necessary to power the blog.

Make sure you have the latest dotnet new Umbraco template installed:

dotnet new install Umbraco.Templates

Then create an Umbraco site with the Delivery API enabled, and install the Headless Preview package:

dotnet new umbraco --use-delivery-api
dotnet add package Kjac.HeadlessPreview

To avoid having to create all the content yourself, download the Umbraco database and media files from my GitHub repo, and copy them to your Umbraco site at /umbraco/Data/ and /wwwroot/media/, respectively.

Update appsettings.json to enable Delivery API preview, and add the database connection string:

{
  // ...
  "Umbraco": {
    "CMS": {
      // ...
      "DeliveryApi": {
        "Enabled": true,
        // 👇 add this 👇
        "ApiKey": "super-secret-api-key"
      },
      // ...
    }
  },
  // 👇 add this 👇
  "ConnectionStrings": {
    "umbracoDbDSN": "Data Source=|DataDirectory|/Umbraco.sqlite.db;Cache=Shared;Foreign Keys=True;Pooling=True",
    "umbracoDbDSN_ProviderName": "Microsoft.Data.Sqlite"
  }
}

If you start the site with dotnet run, you can now log into Umbraco with:

  • Username: admin@localhost
  • Password: SuperSecret123

The added preview functionality of the Headless Preview package doesn’t look like much yet, though:

The default Headless Preview UI

Step 2: Setup the Next.js frontend

I’ve previously written about the Next.js Umbraco blog example, so this will be a quick reiteration.

First run npx create-next-app --example cms-umbraco umbraco-app to setup the sample locally.

In the resulting umbraco-app directory, rename .env.local.example to .env.local and replace its content with:

# This is necessary when you run locally against a self-signed server.
# Do NOT include this in production.
NODE_TLS_REJECT_UNAUTHORIZED=0

# Add your Umbraco server URL here. Please do not include a trailing slash.
UMBRACO_SERVER_URL = 'https://localhost:44367'

# Add your Umbraco Delivery API key here if you want to use preview.
UMBRACO_DELIVERY_API_KEY = 'super-secret-api-key'

# Add the secret token that will be used to "authorize" preview
UMBRACO_PREVIEW_SECRET = 'super-secret-preview-key'

A few tweaks are necessary to bridge the Next.js frontend and the Umbraco site for previewing.

Redirect in “Draft Mode”

The Next.js frontend includes an example of using Draft Mode to fetch preview content, but the example is rather simplistic; it always redirects to the front page of the blog when Draft Mode is enabled.

This won’t work for the content editors. They expect to see a preview of the content they’re working on in Umbraco, which is not necessarily the front page. So - a redirect system must be put in place.

Open preview.ts from umbraco-app/pages/api, and extract a redirect parameter from the querystring:

const { secret, redirect } = req.query;

Now use the redirect parameter for redirecting if it’s set, and use the existing root redirect as fallback:

// redirect to the redirect target if any has been provided, otherwise just redirect to root
const redirectValue = redirect as string;
if (redirectValue ) {
    res.redirect(redirectValue);
}
else  {
    res.redirect("/");
}

Fetching draft content from Umbraco

Umbraco creates special “preview” routes to route unpublished content for preview. Alas, the Next.js frontend currently breaks when encountering a “preview” route 🙄

Fortunately there is a quick fix for it. Find api.ts in umbraco-app/lib and add a leading slash to the slug in fetchSingle:

const fetchSingle = async (slug: string, startItem: string, preview: boolean) =>
  // add a leading '/' to the item slug here 👇
  await performFetch(`${UMBRACO_API_URL}/item//${slug}`, {
    method: "GET",
    // ...

Run Next.js with HTTPS on localhost

The Headless Preview is essentially just an iframe inside Umbraco. To successfully load the Next.js frontend in this iframe, it must run with HTTPS. For local development, this involves a self-signed certificate for the Next.js development server.

Luckily it’s a lot simpler than it sounds; you just have to run Next.js with —experimental-https.

I did have some issues getting that to work directly on the command line, so I ended up adding it to package.json in umbraco-app - that works like a charm 😊

{
  "private": true,
  "scripts": {
    "dev": "next dev --experimental-https",
    // ...

Step 3: Connecting Umbraco and Next.js

The Next.js frontend should now be up and running on HTTPS, presenting published blog posts from the Umbraco site.

The Next.js frontend

The last step is to load the Next.js frontend into the Headless Preview iframe. To do this, an implementation of IDocumentPreviewService is required to calculate the preview URL for a specific document:

using Kjac.HeadlessPreview.Models;
using Kjac.HeadlessPreview.Services;
using Umbraco.Cms.Core.Models;

namespace Server.Services;

public class BlogDocumentPreviewService : IDocumentPreviewService
{
    private const string PreviewHost = "https://localhost:3000";

    private const string PreviewSecret = "super-secret-preview-key";

    public Task<DocumentPreviewUrlInfo> PreviewUrlInfoAsync(IContent document, string? culture, string? segment)
    {
        var redirect = document.ContentType.Alias switch
        {
            "post" => $"/posts/preview-{document.Key}",
            "posts" => "/",
            _ => throw new ArgumentException($"Unsupported document type: {document.ContentType.Alias}", nameof(document))
        };

        return Task.FromResult(new DocumentPreviewUrlInfo
        {
            PreviewUrl = $"{PreviewHost}/api/preview?secret={PreviewSecret}&redirect={redirect}",
        });
    }
}

Use an IComposer implementation to swap the default implementation with this one:

using Kjac.HeadlessPreview.Services;
using Server.Services;
using Umbraco.Cms.Core.Composing;

namespace Server.Composing;

public class BlogDocumentPreviewServiceComposer : IComposer
{
    public void Compose(IUmbracoBuilder builder)
        => builder.Services.AddUnique<IDocumentPreviewService, BlogDocumentPreviewService>();
}

The Next.js frontend can only route the front page and the blog posts. Thus, the code above is unable to calculate preview URLs for any other document types.

To prevent previews that cannot be routed, the package features an allow-list of supported document type aliases, which can be defined in appsettings.json:

{
  // ...
  // 👇 add this 👇
  "HeadlessPreview": {
    "SupportedDocumentTypes": ["post", "posts"]
  }
}

With these bits in place, restart the Umbraco site. The Next.js frontend is now rendered in Draft Mode within the Headless Preview:

The Next.js frontend displayed in Headless Preview

Tadaaa 🎉

…but Draft Mode is broken on other domains? 🥲

So, everything works just fine on localhost… but if you run either Umbraco or the Next.js frontend on a different host, you’ll likely get this:

The Next.js 404 screen

A lovely 404 🤨

Upon closer investigation, it turns out that the Next.js Draft Mode relies on a cookie called __prerender_bypass, and it is set with SameSite=Lax. A quick search on the big ol’ internet will tell you that this is a headache for a fair few CMS’es relying on iframes for previewing.

The same search will also lead you to a range of suggested workarounds 🙃

I tried out a few of these workarounds, and here’s one that seems to work splendidly: In preview.ts, insert the following just before performing the redirect:

// support iframe previews by enforcing "SameSite=None; Secure=True" for preview the cookie
const responseCookies = res.getHeader("Set-Cookie") as string[];
if (responseCookies) {
  const cookies = responseCookies.map((cookie) => {
    if (cookie.match(/^(__prerender_bypass|__next_preview_data)=/)) {
      return cookie.replace(/SameSite=Lax/, "SameSite=None; Secure=True");
    }

    return cookie;
  });

  res.setHeader("Set-Cookie", cookies);
}

Advanced stuff

The Headless Preview package comes with a few bells and whistles to spice up the editor experience 🔔🪈

I won’t go into detail about them here, they’re all described in the package repository README file. If you want to see them in action, they’re also featured in the GitHub repo for this blog post.

The future of Headless Preview?

The Headless Preview package addresses an immediate need for previewing headless sites from within Umbraco.

That being said, I do expect Umbraco to ship some kind of headless preview support soon - for V16 or perhaps even sooner 🤞

This brings me to my primary reason for creating this package: I would like to start a discussion about what headless preview should look like from an Umbraco perspective.

To that end, I’d love to hear your thoughts and ideas. You can catch me on LinkedIn 😊

Until next time 💜