Razor Islands

Razor Islands add SPA navigation to static content sites. Each island is a named region of the page that updates independently when the user navigates, while the surrounding layout stays in place. The first page load is always full static HTML; subsequent navigations fetch a JSON envelope and swap island contents.

Registration

services.AddContentEngineService(...)
    .WithMarkdownContentService<TFrontMatter>(...)
    .WithSpaNavigation<TFrontMatter>(spa =>
    {
        spa.AddIsland<MyContentIslandRenderer>();
        spa.AddIsland<MySidebarIslandRenderer>();
    });
  
// In the middleware pipeline:
app.UseSpaNavigation();

WithSpaNavigation<TFrontMatter>() registers:

Service Lifetime Purpose
SpaPageDataService Transient Resolves metadata from PageToGenerate.Metadata across all content services and orchestrates island renderers into a SpaPageEnvelope
ComponentRenderer Scoped Wraps Blazor's HtmlRenderer for Razor-based renderers
SpaNavigationContentService Transient IContentService that generates _spa-data/*.json for all registered content services during static builds

UseSpaNavigation() maps the endpoint that serves the JSON envelope for each page.

ISpaIslandRenderer

The core interface for producing HTML content for a named island.

public interface ISpaIslandRenderer
{
    string IslandName { get; }
    Task<string?> RenderAsync(string url);
}
Member Description
IslandName Must match a data-spa-island attribute in the layout. Multiple renderers for the same name are allowed — only the last registered one runs.
RenderAsync Receives the content page URL (e.g. "/", "/pasta-carbonara"). Return null to omit this island from the envelope.

RazorIslandRenderer<TComponent>

Abstract base class that renders a Blazor component to a string via ComponentRenderer. Subclasses provide the island name and build a parameter dictionary; the base class handles the HtmlRenderer ceremony.

public abstract class RazorIslandRenderer<TComponent>(
    ComponentRenderer renderer) : ISpaIslandRenderer
    where TComponent : IComponent
{
    public abstract string IslandName { get; }
    protected abstract Task<IDictionary<string, object?>?> BuildParametersAsync(string url);
}

BuildParametersAsync returns null to skip the island for that page, or a dictionary of component parameters keyed by nameof(Component.Property).

Note

Components rendered via RazorIslandRenderer support @inject for DI services but cannot use JavaScript interop, NavigationManager, or other browser-dependent APIs.

Page Metadata

The title and description fields in the JSON envelope are resolved automatically from PageToGenerate.Metadata across all registered IContentService instances. Any content service that populates Metadata.Title on its pages will participate in SPA navigation — no additional registration is needed.

SpaPageEnvelope

The JSON payload returned for each page.

{
  "title": "Pasta Carbonara",
  "description": "Classic Roman carbonara with guanciale and pecorino",
  "islands": {
    "content": "<header>…</header><div class=\"prose\">…</div>",
    "recipe-info": "<div class=\"rounded-lg\">…</div>"
  }
}
Field Type Description
title string Page title — used by the SPA engine to set document.title
description string? Page description — updates <meta name="description">
islands object Map of island name → HTML string. Keys match data-spa-island attributes.

Data Attributes

Add these to layout elements to make them Razor Islands.

data-spa-island

Marks an element as an island. The value is the island name.

<article data-spa-island="content">
    @Body
</article>

data-spa-loading

Controls what happens to the island while a slow fetch is in progress.

Value Behaviour
"skeleton" Shows a shimmer placeholder (or a custom <template>, see below)
"clear" Empties the island immediately
"keep" Leaves previous content in place until new data arrives (default)
<article data-spa-island="content" data-spa-loading="skeleton">

Custom Skeleton Templates

Provide a <template> element with data-spa-skeleton-for matching the island name:

<template data-spa-skeleton-for="content">
    <div class="animate-pulse space-y-4">
        <div class="h-8 bg-neutral-200 rounded w-3/4"></div>
        <div class="h-4 bg-neutral-200 rounded w-full"></div>
        <div class="h-4 bg-neutral-200 rounded w-5/6"></div>
    </div>
</template>

If no custom template exists, the engine shows a default shimmer skeleton.

Configuration via <html> Attributes

The SPA engine reads configuration from data-* attributes on the <html> element.

Attribute Default Purpose
data-base-url (on <body>) "" Base URL prefix for subdirectory deployments (set automatically by BaseUrlRewritingMiddleware)
data-spa-data-path "/_spa-data" URL prefix for page data JSON files
data-spa-skeleton-delay "100" Milliseconds before showing the skeleton (fast fetches skip it)
data-spa-min-skeleton "250" Minimum milliseconds to show the skeleton once visible

Lifecycle Events

The SPA engine dispatches custom events on document.

spa:before-navigate

Fires before the fetch starts. Use it to clear transient UI state.

document.addEventListener('spa:before-navigate', (e) => {
    const { url } = e.detail;
    // Clear tooltips, close modals, etc.
});

spa:commit

Fires after new island content is injected into the DOM. Use it to reinitialise interactive features.

document.addEventListener('spa:commit', (e) => {
    const { url, data } = e.detail;
    // Re-highlight code, rebuild outline, update active nav links, etc.
});
Detail field Type Description
url URL The navigated URL
data object The full SpaPageEnvelope (title, description, islands)

SpaNavigationBuilder

Fluent builder passed to the WithSpaNavigation<T>() configure callback.

Method Description
AddIsland<TRenderer>() Registers an ISpaIslandRenderer implementation
WithDataPath(string) Changes the data endpoint path (default "/_spa-data")

Scripts

Include in your App.razor after scripts.js:

<script src="/_content/MyLittleContentEngine.UI/spa-engine.js" defer></script>

The engine activates automatically — no initialisation call needed. It discovers islands from data-spa-island attributes, intercepts same-origin link clicks, and handles history, view transitions, and scroll position.