Crafting an SPA with Umbraco

Crafting an SPA with Umbraco

A single-page application (SPA) often yields a smoother and more responsive end user experience than the traditional multi-page application (MPA).

However, an SPA is inherently harder to build than its MPA counterpart. Stuff like content access, navigation, routing and search engine crawl-ability require a lot of attention and time in the implementation phase for the SPA, while these things are mostly “just” given for the MPA.

In this post I’ll show you how an SPA can be built on top of Umbraco. Hopefully you’ll find that it’s not necessarily as daunting an undertaking as it might seem at first glance.

The end goal

My SPA will be a nostalgia site for the year 1984. I have borrowed a design from W3Schools and tweaked it a little incorporate a multi-level navigation and a 1984 color scheme 😁

The site layout

I have chosen a simplistic design on purpose, but it still encompasses the above mentioned SPA challenges.

If you want to follow along in code, you’ll find the whole thing in this GitHub repo. To run it, clone down the repo and execute dotnet run in the src/Site directory.

The Umbraco database is bundled up in the repo, so it should just run. If you want to tinker with the content, you can login to the Umbraco backoffice with:

  • Username: admin@localhost
  • Password: SuperSecret123

Umbraco and the content model

The Umbraco content model looks like this:

The Umbraco content model

As you can see, the model consists of a few different content types:

  • A homePage for the site root.
  • A landingPage for the subject landing pages.
  • An articlePage for the individual articles.
  • A notFoundPage for custom 404 responses.

With the Content Delivery API enabled, Umbraco combines the strengths of a traditional CMS with those of a headless one, efficiently turning it into a hybrid CMS. This happens to be a pretty great fit for building an SPA:

  • The first page load can be served directly, with Umbraco acting as head.
  • Subsequent page loads can use the Delivery API for headless content access.

A nice detail about the Delivery API is that specific content items can be accessed both by their IDs and their route (path). This means that internal links in the SPA can be rendered as regular href links without compromising on the client-side routing. And this in turn both improves both the site usability, and helps search engines crawl the site.

Content routing

Since Umbraco acts as a hybrid CMS, server-side routing of the first page load “just works”. That’s one less thing to worry about 😅

I have chosen the Vaadin Router to handle client-side routing, because it offers two important features for my SPA:

  1. Dynamic routing. Since the SPA is backed entirely by the CMS, no routes are known up-front (except the root). Thus, all internal links must be resolved dynamically at runtime.
  2. Dynamic rendering. The type of content matching any given route can only be determined once the content has been fetched from the Delivery API. Therefore, the router must be able to perform conditional rendering, based on the fetched content.

The Vaadin Router is built for Web Components, which means I can create a Web Component for each content type in the content model. That’s a perfect way to encapsulate the rendering of each type 👍

All of the above sounds terribly advanced, but fortunately it boils down to just a few lines of code:

import {Router} from 'https://cdn.jsdelivr.net/npm/@vaadin/[email protected]/+esm';

const loadContent = async (context, commands) => {
    // fetch content from the Delivery API 
    const response = await fetch(`/umbraco/delivery/api/v2/content/item${context.path}`);

    // store the response on the DOM root for the Web Components (fake a 404 page if the request failed)
    document.umbCurrentPage = response.ok
        ? await response.json()
        : {contentType: 'notFoundPage', route: {}, properties: {}};

    return renderContent(commands);
};

const renderContent = (commands) => {
    // translate content type alias (e.g. homePage) to its corresponding Web Component name (e.g. home-page)
    const customElementName = document.umbCurrentPage.contentType.replace(/[A-Z]/g, (match, offset) => (offset > 0 ? '-' : '') + match.toLowerCase());
    return commands.component(customElementName);
};

// setup the router - all routes should be handled by loading content from the Delivery API
const outlet = document.getElementById('outlet');
const router = new Router(outlet);
router.setRoutes([{path: '(.*)', action: loadContent}]);

As you can tell from the router code, I’m storing the Delivery API response in document.umbCurrentPage for the subsequent Web Component rendering. While this works like a charm, it’s not necessarily pretty. I’m sure someone with better frontend mojo can do better 😆

Here’s an outline of the client-side routing flow:

  1. The end user navigates to a new page.
  2. The router intercepts the route change before the browser handles it.
  3. The router fetches the page content from the Delivery API.
  4. The corresponding Web Component is rendered in the router “outlet”.

Web Components

There are four Web Components for content page rendering - one for each of the content types in the content model. I have built them using LitElement, but that’s just my preference; anything Web Component will do the trick.

All content type components inherit from a base component, which defines some common styles. It also hides away the somewhat hackyness of fetching content properties from document.umbCurrentPage:

import {LitElement, html, css, unsafeHTML} from 'https://cdn.jsdelivr.net/gh/lit/dist@3/all/lit-all.min.js';

// base class for all page types
export class BasePage extends LitElement {
    static get styles() {
        return css`
            /* omitted for brevity */
        `;
    }

    properties() {
        return document.umbCurrentPage.properties;
    }
}

// the home page element
export class HomePage extends BasePage {
    static get styles() {
        return [
            super.styles
        ];
    }

    render() {
        const properties = super.properties();
        return html`
            /* omitted for brevity */
        `;
    }
}

// the landing page element
export class LandingPage extends BasePage {
    // ...
}

Incidentally, the content types are very similar in this demo, so the Web Component renderings all look very alike. But they still serve their purpose - demonstrating an SPA setup with multiple, independent content type renderings.

You’ll find all the Web Components in components.js.

Handling navigation changes

As the user navigates through the site, a few things need to happen on the client:

  1. The navigation menu must visualize the route changes - e.g. highlight the active route.
  2. The page metadata needs updating to reflect the newly loaded page content.

Since the navigation structure remains static for the duration of an end user session, I can leverage the hybrid CMS capabilities and perform a server-side rendering of the entire navigation structure on the first page load.

The server also renders the page metadata for the first page.

Fortunately, the Vaadin Router does not interfere with the popstate event in the browser, so I have opted to tie a bit of vanilla JS to that event. A bit of DOM manipulation adds interactivity to the navigation menu, and updates the page metadata as the route changes.

Again, this works just fine, but certain frontenders might frown at it 🙈

Server-side rendering

With all page specific rendering being handled by the Web Components, I only need a single Razor template for all the content types in my content model.

The template renders the page structure (including the metadata and the surrounding navigation structure as mentioned above), but not the page content; this is handled by the router, which inserts the applicable Web Component in the <div id="outlet"></div> element.

And this is where I have a little trick to boost the performance of the first page load 🚀

When Umbraco renders the template, the requested content page is readily available for rendering - after all, this is what an MPA would be doing in the first place. It seems silly to discard this content, only to request it again through the Delivery API once the router is initialized on the client.

That’s why the Delivery API output (JSON) for the first page is rendered as document.umbCurrentPage at the very bottom of the template.

The Delivery API output is generated by the DefaultController, which backs the template. This controller is configured as the default Umbraco RenderController using a Composer, which causes all content types to be rendered by the controller 🤓

Wrapping up 🎁

The combination of the Delivery API and the traditional Razor rendering feels like a potent mix for a hybrid solution. While the Delivery API wasn’t built specifically for this purpose, the option to fetch content by route makes it an appealing choice all the same.

The Delivery API could also serve other purposes when building an SPA. For example, it could:

  • Power a filtered search based on content tagging.
  • Build up page navigations in a less static approach than what’s outlined in this post.

Some of the implementation details in this post are somewhat undercooked or perhaps even naive in their simplicity. On the other hand, we as developers do have a tendency to over-engineer stuff. Perhaps a little simplicity isn’t too shabby after all 🤷

Happy hacking 💜