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
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
IFrontMatterproperties: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, andDifficultyfields 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 needsIFrontMatterfor the base contract. - 2
Create Razor Components for Your Islands
Each island needs a Razor component that produces the HTML to inject. These components are normal
.razorfiles 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
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'sHtmlRenderer.The content island renderer fetches the markdown content and passes it to the
RecipeContentcomponent: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 thedata-spa-islandattribute you'll add to the layout in the next step. - 4
Wire Up the Layout
Add
data-spa-islandattributes to the layout elements that should update during SPA navigation. The attribute value must match theIslandNamefrom 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 navigationdata-spa-island="recipe-info"marks the sidebar — same treatmentdata-spa-loading="skeleton"shows a shimmer placeholder while slow fetches completedata-spa-loading="clear"empties the island immediately (good for small sidebar elements)
- 5
Use the Components in Your SSR Page
Your
Pages.razorrenders 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
RecipeContentandRecipeInfoCardappear here — the same components the island renderers use. Change the component once, both SSR and SPA update. - 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 mainscripts.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.jsscript handles link interception, fetch racing, skeleton display, view transitions, history management, and scroll position — all driven by thedata-spa-islandattributes in your layout. - 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
Run It
dotnet watchNavigate 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
.jsonfetches instead of full HTML page loads.
What Success Looks Like
When you click a recipe link in the sidebar:
- The article content swaps to the new recipe (title, instructions, ingredients)
- The sidebar card updates with the new recipe's prep time, cook time, and servings
- The URL updates in the address bar and browser back/forward works
- 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