Adding SPA Navigation

Out of the box, MyLittleContentEngine generates static HTML — every link click triggers a full page load. That's fast enough for most sites, but for content-heavy sites with sidebar navigation it can feel sluggish. SPA navigation fixes this by fetching only the page data on subsequent clicks and swapping content in-place, while keeping the first page load as fast static HTML.

In this tutorial, you'll add SPA navigation to a recipe site. The same approach works for documentation, blogs, or any content site.

What You'll Build

  • A recipe site where clicking between recipes swaps content instantly — no full page reload
  • Named islands in the layout that update independently (article content + a recipe info sidebar)
  • Razor components that render both the initial static page and SPA updates from a single source of truth

How It Works

The first visit to your site loads full static HTML — normal browser behaviour. After that, clicking an internal link fetches a small JSON file containing pre-rendered HTML for each named island, and the SPA engine swaps the content without a reload. If the JSON isn't available (custom Razor pages, external links), it falls back to a normal page load gracefully.

Prerequisites

  • A working MyLittleContentEngine site (see Creating Your First Site)
  • Familiarity with Blazor components and front matter
  1. 1

    Define Your Front Matter

    Create a front matter class with the fields your site needs. For a recipe site, that includes cooking metadata alongside the standard IFrontMatter properties:

    using MyLittleContentEngine.Models;
      
    namespace SpaNavigationExample;
      
    public class RecipeFrontMatter : IFrontMatter
    {
        public string Title { get; init; } = "";
        public string Description { get; init; } = "";
        public int PrepTime { get; init; }
        public int CookTime { get; init; }
        public int Servings { get; init; }
        public string Difficulty { get; init; } = "Easy";
        public string[] Tags { get; init; } = [];
        public bool IsDraft { get; init; }
        public string? Uid { get; init; }
        public string? RedirectUrl { get; init; }
        public string? Section { get; init; }
      
        public Metadata AsMetadata() => new()
        {
            Title = Title,
            Description = string.IsNullOrEmpty(Description) ? null : Description,
            RssItem = false,
        };
    }
    

    The PrepTime, CookTime, Servings, and Difficulty fields are recipe-specific — your site would have whatever domain fields make sense. The SPA system doesn't care about the shape of your front matter; it just needs IFrontMatter for the base contract.

  2. 2

    Create Razor Components for Your Islands

    Each island needs a Razor component that produces the HTML to inject. These components are normal .razor files with [Parameter] properties — no special base class or interface needed.

    First, the main content component that renders the article title and markdown body:

    <header class="mb-6 lg:mb-8">
        <h1 class="text-2xl lg:text-3xl font-bold tracking-tight text-neutral-900">@Title</h1>
    </header>
    <div class="prose prose-neutral max-w-full">
        @((MarkupString)HtmlContent)
    </div>
      
    @code {
        [Parameter] public string Title { get; set; } = "";
        [Parameter] public string HtmlContent { get; set; } = "";
    }
    

    Then a sidebar component for recipe metadata:

    <div class="rounded-lg border border-neutral-200 bg-neutral-50 p-4 text-sm">
        <h3 class="font-semibold text-neutral-900 mb-3">Recipe Info</h3>
        <dl class="space-y-2">
            <div class="flex justify-between">
                <dt class="text-neutral-500">Prep time</dt>
                <dd class="font-medium text-neutral-800">@PrepTime min</dd>
            </div>
            <div class="flex justify-between">
                <dt class="text-neutral-500">Cook time</dt>
                <dd class="font-medium text-neutral-800">@CookTime min</dd>
            </div>
            <div class="flex justify-between">
                <dt class="text-neutral-500">Total</dt>
                <dd class="font-medium text-neutral-800">@(PrepTime + CookTime) min</dd>
            </div>
            <div class="flex justify-between border-t border-neutral-200 pt-2">
                <dt class="text-neutral-500">Servings</dt>
                <dd class="font-medium text-neutral-800">@Servings</dd>
            </div>
            <div class="flex justify-between">
                <dt class="text-neutral-500">Difficulty</dt>
                <dd class="font-medium text-neutral-800">@Difficulty</dd>
            </div>
        </dl>
      
        @if (Tags.Length > 0)
        {
            <div class="flex flex-wrap gap-1.5 mt-3 pt-3 border-t border-neutral-200">
                @foreach (var tag in Tags)
                {
                    <span class="text-xs bg-neutral-100 text-neutral-600 px-2 py-0.5 rounded-full">@tag</span>
                }
            </div>
        }
    </div>
      
    @code {
        [Parameter] public int PrepTime { get; set; }
        [Parameter] public int CookTime { get; set; }
        [Parameter] public int Servings { get; set; }
        [Parameter] public string Difficulty { get; set; } = "Easy";
        [Parameter] public string[] Tags { get; set; } = [];
    }
    

    Tip

    These same components are used by both the initial SSR page load and the Razor Island system. One component, two rendering paths — no duplicated markup.

  3. 3

    Write Island Renderers

    An island renderer connects your data to your Razor component. It fetches the content page, extracts the data it needs, and returns a parameter dictionary. The base class RazorIslandRenderer<TComponent> handles the actual rendering via Blazor's HtmlRenderer.

    The content island renderer fetches the markdown content and passes it to the RecipeContent component:

    using MyLittleContentEngine.Services.Content;
    using MyLittleContentEngine.Services.Spa;
    using SpaNavigationExample.Slots.Components;
      
    namespace SpaNavigationExample.Slots;
      
    /// <summary>
    /// Custom slot renderer that wraps the rendered markdown with a title header.
    /// Demonstrates how sites delegate presentation to a Razor component
    /// while keeping data-fetching logic in the renderer.
    /// </summary>
    public class RecipeContentSlotRenderer(
        IMarkdownContentService<RecipeFrontMatter> contentService,
        ComponentRenderer renderer) : RazorIslandRenderer<RecipeContent>(renderer)
    {
        public override string IslandName => "content";
      
        protected override async Task<IDictionary<string, object?>?> BuildParametersAsync(string url)
        {
            var result = await contentService.GetRenderedContentPageByUrlOrDefault(url);
            if (result is null) return null;
      
            return new Dictionary<string, object?>
            {
                [nameof(RecipeContent.Title)] = result.Value.Page.FrontMatter.Title,
                [nameof(RecipeContent.HtmlContent)] = result.Value.HtmlContent,
            };
        }
    }
    

    The sidebar island renderer reads recipe-specific front matter fields:

    using MyLittleContentEngine.Services.Content;
    using MyLittleContentEngine.Services.Spa;
    using SpaNavigationExample.Slots.Components;
      
    namespace SpaNavigationExample.Slots;
      
    /// <summary>
    /// Custom slot renderer that produces a recipe metadata card for the sidebar.
    /// Demonstrates a Razor-based slot renderer using <see cref="RazorIslandRenderer{TComponent}"/>
    /// to delegate presentation to a <c>.razor</c> component.
    /// </summary>
    public class RecipeInfoSlotRenderer(
        IMarkdownContentService<RecipeFrontMatter> contentService,
        ComponentRenderer renderer) : RazorIslandRenderer<RecipeInfoCard>(renderer)
    {
        public override string IslandName => "recipe-info";
      
        protected override async Task<IDictionary<string, object?>?> BuildParametersAsync(string url)
        {
            var result = await contentService.GetRenderedContentPageByUrlOrDefault(url);
            if (result is null) return null;
      
            var fm = result.Value.Page.FrontMatter;
      
            // Index page has no recipe metadata.
            if (fm is { PrepTime: 0, CookTime: 0, Servings: 0 })
                return null;
      
            return new Dictionary<string, object?>
            {
                [nameof(RecipeInfoCard.PrepTime)] = fm.PrepTime,
                [nameof(RecipeInfoCard.CookTime)] = fm.CookTime,
                [nameof(RecipeInfoCard.Servings)] = fm.Servings,
                [nameof(RecipeInfoCard.Difficulty)] = fm.Difficulty,
                [nameof(RecipeInfoCard.Tags)] = fm.Tags,
            };
        }
    }
    

    Each renderer declares an IslandName — this must match the data-spa-island attribute you'll add to the layout in the next step.

  4. 4

    Wire Up the Layout

    Add data-spa-island attributes to the layout elements that should update during SPA navigation. The attribute value must match the IslandName from your renderers.

    @using MyLittleContentEngine.Services.Content.TableOfContents
    @using System.Collections.Immutable
    @using Microsoft.AspNetCore.Components.Sections
    @inherits LayoutComponentBase
    @inject ITableOfContentService TableOfContentService
    @inject NavigationManager NavigationManager
      
    <div class="min-h-screen flex flex-col">
        <!-- Header -->
        <header class="border-b border-neutral-200 bg-white px-6 py-3 flex items-center gap-4">
            <a href="/" class="text-lg font-bold text-neutral-900 hover:text-neutral-700 transition">
                My Recipe Book
            </a>
            <span class="text-neutral-400 text-sm">SPA Slots Demo</span>
        </header>
      
        <div class="flex flex-1">
            <!-- Left sidebar: navigation -->
            <nav class="w-64 border-r border-neutral-200 bg-neutral-50 flex-shrink-0">
                <div class="sticky top-0 h-screen overflow-y-auto p-6">
                    <TableOfContentsNavigation TableOfContents="@_tableOfContents" />
                </div>
            </nav>
      
            <!-- Main content area with SPA slot -->
            <div class="flex-1 min-w-0 p-6 lg:p-10">
                <article data-spa-island="content" data-spa-loading="skeleton">
                    @Body
                </article>
            </div>
      
            <!-- Right sidebar: recipe info with SPA slot -->
            <aside class="w-64 flex-shrink-0 p-6 hidden lg:block">
                <div class="sticky top-6" data-spa-island="recipe-info" data-spa-loading="clear">
                    <SectionOutlet SectionName="recipe-info" />
                </div>
            </aside>
        </div>
    </div>
      
    @code {
        private ImmutableList<NavigationTreeItem>? _tableOfContents;
      
        protected override async Task OnInitializedAsync()
        {
            _tableOfContents = await TableOfContentService.GetNavigationTocAsync(
                NavigationManager.ToAbsoluteUri(NavigationManager.Uri).AbsolutePath);
        }
    }
    

    The key attributes:

    • data-spa-island="content" marks the article area — the SPA engine replaces its contents on navigation
    • data-spa-island="recipe-info" marks the sidebar — same treatment
    • data-spa-loading="skeleton" shows a shimmer placeholder while slow fetches complete
    • data-spa-loading="clear" empties the island immediately (good for small sidebar elements)
  5. 5

    Use the Components in Your SSR Page

    Your Pages.razor renders the initial static HTML. Use the same Razor components you created for the island renderers:

    @page "/{*fileName:nonfile}"
      
    @using MyLittleContentEngine.Models
    @using MyLittleContentEngine.Services.Content
    @using MyLittleContentEngine
    @using Microsoft.AspNetCore.Components.Sections
    @using SpaNavigationExample.Slots.Components
    @inject ContentEngineOptions ContentEngineOptions
    @inject IMarkdownContentService<RecipeFrontMatter> MarkdownContentService
      
    @if (_post is not null && _postContent is not null)
    {
        <PageTitle>@ContentEngineOptions.SiteTitle - @_post.FrontMatter.Title</PageTitle>
        <HeadContent>
            @if (!string.IsNullOrWhiteSpace(_post.FrontMatter.Description))
            {
                <meta name="description" content="@_post.FrontMatter.Description" />
                <meta property="og:description" content="@_post.FrontMatter.Description" />
            }
        </HeadContent>
      
        <RecipeContent Title="@_post.FrontMatter.Title" HtmlContent="@_postContent" />
      
        @if (_post.FrontMatter.PrepTime > 0 || _post.FrontMatter.CookTime > 0)
        {
            <SectionContent SectionName="recipe-info">
                <RecipeInfoCard PrepTime="@_post.FrontMatter.PrepTime"
                                CookTime="@_post.FrontMatter.CookTime"
                                Servings="@_post.FrontMatter.Servings"
                                Difficulty="@_post.FrontMatter.Difficulty"
                                Tags="@_post.FrontMatter.Tags" />
            </SectionContent>
        }
    }
    else
    {
        <PageTitle>@ContentEngineOptions.SiteTitle</PageTitle>
        <div class="flex justify-center items-center h-64">
            <p class="text-neutral-500">Page not found</p>
        </div>
    }
      
    @code {
        private MarkdownContentPage<RecipeFrontMatter>? _post;
        private string? _postContent;
      
        [Parameter] public required string FileName { get; init; } = string.Empty;
      
        protected override async Task OnInitializedAsync()
        {
            var fileName = string.IsNullOrWhiteSpace(FileName) ? "index" : FileName;
            var page = await MarkdownContentService.GetRenderedContentPageByUrlOrDefault(fileName);
            if (page is null) return;
      
            _post = page.Value.Page;
            _postContent = page.Value.HtmlContent;
        }
    }
    

    Notice RecipeContent and RecipeInfoCard appear here — the same components the island renderers use. Change the component once, both SSR and SPA update.

  6. 6

    Register Services and Include Scripts

    In Program.cs, chain .WithSpaNavigation<T>() after your markdown content service and register your island renderers:

    using MyLittleContentEngine;
    using MyLittleContentEngine.MonorailCss;
    using MyLittleContentEngine.Services.Spa;
    using SpaNavigationExample;
    using SpaNavigationExample.Components;
    using SpaNavigationExample.Slots;
      
    var builder = WebApplication.CreateBuilder(args);
      
    builder.Services.AddRazorComponents();
      
    builder.Services.AddContentEngineService(_ => new ContentEngineOptions
        {
            SiteTitle = "My Recipe Book",
            SiteDescription = "A cookbook powered by SPA slots",
            ContentRootPath = "Content",
        })
        .WithMarkdownContentService(_ => new MarkdownContentOptions<RecipeFrontMatter>
        {
            ContentPath = "Content",
            BasePageUrl = "",
        })
        .WithSpaNavigation<RecipeFrontMatter>(spa =>
        {
            // Replace the built-in content renderer with our recipe-aware one
            // that includes the title header and prose wrapper.
            spa.AddIsland<RecipeContentSlotRenderer>();
      
            // Add the domain-specific recipe info sidebar slot.
            spa.AddIsland<RecipeInfoSlotRenderer>();
        });
      
    builder.Services.AddMonorailCss();
      
    var app = builder.Build();
    app.UseAntiforgery();
    app.UseStaticFiles();
    app.MapRazorComponents<App>();
    app.UseMonorailCss();
    app.UseSpaNavigation();
      
    await app.RunOrBuildContent(args);
    

    Then include the SPA engine script in your App.razor (after the main scripts.js):

    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="utf-8"/>
        <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
      
        <link rel="stylesheet" href="/styles.css" />
        <script src="/_content/MyLittleContentEngine.UI/scripts.js" defer></script>
        <script src="/_content/MyLittleContentEngine.UI/spa-engine.js" defer></script>
      
        <HeadOutlet/>
    </head>
      
    <body>
        <Routes/>
    </body>
    </html>
    

    The spa-engine.js script handles link interception, fetch racing, skeleton display, view transitions, history management, and scroll position — all driven by the data-spa-island attributes in your layout.

  7. 7

    Add Some Content

    Create a few markdown files with your front matter fields. Here's an example recipe:

    ---
    title: Pasta Carbonara
    description: Classic Roman carbonara with guanciale and pecorino
    prepTime: 10
    cookTime: 20
    servings: 4
    difficulty: Medium
    order: 2
    tags:
      - Italian
      - Pasta
      - Quick
    ---
    
    A classic Roman pasta dish made with eggs, hard cheese, cured pork, and black pepper.
    
    ## Ingredients
    
    - 400g spaghetti or rigatoni
    - 200g guanciale (or pancetta), cut into small strips
    - 4 large egg yolks + 2 whole eggs
    - 100g Pecorino Romano, finely grated
    - Freshly ground black pepper
    
    ## Instructions
    
    1. Bring a large pot of salted water to a boil and cook the pasta until al dente.
    2. While the pasta cooks, cook the guanciale in a cold pan over medium heat until the fat renders and the meat is crispy — about 8 minutes.
    3. In a bowl, whisk together the egg yolks, whole eggs, and most of the Pecorino. Season generously with black pepper.
    4. When the pasta is ready, reserve a cup of pasta water, then drain.
    5. Remove the guanciale pan from heat. Add the hot pasta and toss to coat in the rendered fat.
    6. Pour the egg mixture over the pasta and toss vigorously, adding splashes of pasta water to create a creamy sauce. The residual heat cooks the eggs without scrambling.
    7. Serve immediately with the remaining Pecorino and more black pepper.
    
    ## Tips
    
    - **Never** add cream. The creaminess comes from the emulsion of eggs, cheese, and pasta water.
    - Work quickly when combining — the eggs need the heat of the pasta but too much heat will scramble them.
    - Guanciale is traditional, but pancetta works well as a substitute.
    
  8. 8

    Run It

    dotnet watch
    

    Navigate to your site and click between recipes. The content and sidebar swap instantly without a full page reload. Open the browser's Network tab — you'll see small .json fetches instead of full HTML page loads.

What Success Looks Like

When you click a recipe link in the sidebar:

  1. The article content swaps to the new recipe (title, instructions, ingredients)
  2. The sidebar card updates with the new recipe's prep time, cook time, and servings
  3. The URL updates in the address bar and browser back/forward works
  4. The first visit to any page still loads full static HTML — no JavaScript required for the initial render

If a fetch takes longer than 100ms (slow network, cold cache), you'll see a shimmer skeleton in the content area that holds for at least 250ms to avoid a flash.

Responding to Navigation Events

The SPA engine fires spa:commit on document after new content is injected. Use this to reinitialise interactive features:

document.addEventListener('spa:commit', (e) => {
    // e.detail contains { url, data }
    window.pageManager?.syntaxHighlighter?.init();
    window.pageManager?.tabManager?.init();
});

A spa:before-navigate event fires before the fetch starts, useful for clearing transient UI state.

Next Steps

  • Razor Islands Reference — full API reference for interfaces, data attributes, JSON envelope, and lifecycle events
  • Using DocSite — DocSite uses SPA navigation out of the box with built-in content managers and outline rebuilding
  • Custom Content Service — build an island renderer backed by a non-markdown content source