← back to index

04. Navigation API + DPU

Links inside this demo are intercepted by navigation.addEventListener('navigate'), fetched as fragments, and streamed into #content via streamHTMLUnsafe(). URL and history both behave correctly. Without the API, full reloads work too because each route renders standalone.

declarative partial updates

processing instructions plus <template for> let the server stream patches into a long-lived response.

← back to posts

learn more

source

Everything the server runs for this example. Each file links to GitHub so you can copy or fork it.

handler.ts/app/src/examples/04-navigation-api/handler.ts
import { sleep, streamingResponse } from "../../lib/streaming.ts";
import { loadShell, readSibling, tryStatic } from "../../lib/files.ts";

interface Page {
  title: string;
  body: string[];
}

const CONTENT: { pages: Record<string, Page>; posts: Record<string, Page> } = JSON.parse(
  readSibling(import.meta.url, "content.json"),
);

const TABS: [string, string][] = [
  ["home", "/04/home"],
  ["posts", "/04/posts"],
  ["about", "/04/about"],
];

function renderTabs(currentPath: string): string {
  return TABS.map(([label, href]) => {
    const aria = currentPath === href ? ` aria-current="page"` : "";
    return `<a href="${href}"${aria}>${label}</a>`;
  }).join("");
}

function pageFor(subPath: string): Page | null {
  const m1 = subPath.match(/^\/(home|about|posts)$/);
  if (m1) return CONTENT.pages[m1[1]];
  const m2 = subPath.match(/^\/post\/([\w-]+)$/);
  if (m2) return CONTENT.posts[m2[1]] ?? null;
  return null;
}

function renderShell(currentPath: string, page: Page): string {
  return loadShell(import.meta.url, "shell.html", {
    title: `04 · ${page.title}`,
    tabs: renderTabs(currentPath),
    body: page.body.join(""),
  });
}

export default function handle(_req: Request, path: string): Response | Promise<Response> {
  if (path === "/" || path === "/index.html") {
    return new Response(renderShell("/04/home", CONTENT.pages.home), {
      headers: { "content-type": "text/html; charset=utf-8" },
    });
  }

  const ssrMatch = path.match(/^\/(home|about|posts|post\/[\w-]+)$/);
  if (ssrMatch) {
    const page = pageFor(path);
    if (page) {
      return new Response(renderShell(`/04${path}`, page), {
        headers: { "content-type": "text/html; charset=utf-8" },
      });
    }
  }

  const fragMatch = path.match(/^\/fragment\/(home|about|posts|post\/[\w-]+)$/);
  if (fragMatch) {
    const page = pageFor("/" + fragMatch[1]);
    if (!page) return new Response("<p>not found</p>", { status: 404 });
    return streamingResponse(async (write) => {
      for (const chunk of page.body) {
        await write(chunk);
        await sleep(80);
      }
    });
  }

  return tryStatic(import.meta.url, path) ?? new Response("Not found", { status: 404 });
}

open on GitHub →

client.js/app/src/examples/04-navigation-api/client.js
// SPA shell using Navigation API + streamHTMLUnsafe
const content = document.querySelector("#content");

function setActiveTab(pathname) {
  document.querySelectorAll("nav.tabs a").forEach((a) => {
    const isCurrent = new URL(a.href).pathname === pathname;
    if (isCurrent) a.setAttribute("aria-current", "page");
    else a.removeAttribute("aria-current");
  });
}

if ("navigation" in window) {
  navigation.addEventListener("navigate", (event) => {
    if (!event.canIntercept || event.hashChange || event.downloadRequest) return;
    const url = new URL(event.destination.url);
    if (!url.pathname.startsWith("/04/")) return;
    // let the shell load normally on first nav
    if (url.pathname === "/04/" || url.pathname === "/04") return;
    event.intercept({
      async handler() {
        setActiveTab(url.pathname);
        content.replaceChildren();
        content.innerHTML =
          '<div class="skeleton lg"></div><div class="skeleton"></div><div class="skeleton sm"></div>';
        const fragPath = url.pathname.replace("/04/", "/04/fragment/");
        const res = await fetch(fragPath, { signal: event.signal });
        if (!("streamHTMLUnsafe" in content)) {
          content.innerHTML = await res.text();
          return;
        }
        content.replaceChildren();
        await res.body
          .pipeThrough(new TextDecoderStream())
          .pipeTo(content.streamHTMLUnsafe());
      },
    });
  });
  setActiveTab(location.pathname);
} else {
  console.info("Navigation API not available — falling back to full reloads.");
}

open on GitHub →

content.json/app/src/examples/04-navigation-api/content.json
{
  "pages": {
    "home": {
      "title": "home",
      "body": [
        "<h2 style=\"margin:0 0 .5rem;\">home</h2>",
        "<p>this content is streamed from <code>/04/page/home</code>. The URL in your address bar updated via <code>navigation.intercept()</code>, no full reload happened.</p>",
        "<p>Try the back button. It also goes through the navigate event.</p>"
      ]
    },
    "about": {
      "title": "about",
      "body": [
        "<h2 style=\"margin:0 0 .5rem;\">about</h2>",
        "<p>a tiny SPA built on the Navigation API + Declarative Partial Updates. The whole client is ~25 lines.</p>",
        "<p>If the API is unavailable, links fall back to normal full-page navigations and everything still works because the server can render every page standalone.</p>"
      ]
    },
    "posts": {
      "title": "posts",
      "body": [
        "<h2 style=\"margin:0 0 .5rem;\">posts</h2>",
        "<ul style=\"margin:0;padding-left:1.2rem;\">",
        "<li><a href=\"/04/post/declarative-partial-updates\">declarative partial updates</a></li>",
        "<li><a href=\"/04/post/navigation-api\">the navigation api</a></li>",
        "<li><a href=\"/04/post/streaming-html\">streaming html</a></li>",
        "</ul>"
      ]
    }
  },
  "posts": {
    "declarative-partial-updates": {
      "title": "declarative partial updates",
      "body": [
        "<h2 style=\"margin:0 0 .5rem;\">declarative partial updates</h2>",
        "<p>processing instructions plus <code>&lt;template for&gt;</code> let the server stream patches into a long-lived response.</p>",
        "<p><a href=\"/04/posts\">&larr; back to posts</a></p>"
      ]
    },
    "navigation-api": {
      "title": "navigation api",
      "body": [
        "<h2 style=\"margin:0 0 .5rem;\">the navigation api</h2>",
        "<p>one event (<code>navigate</code>) covers link clicks, form posts, back/forward, programmatic navigation. <code>event.intercept({ handler })</code> takes over.</p>",
        "<p><a href=\"/04/posts\">&larr; back to posts</a></p>"
      ]
    },
    "streaming-html": {
      "title": "streaming html",
      "body": [
        "<h2 style=\"margin:0 0 .5rem;\">streaming html</h2>",
        "<p>HTML has always streamed. DPU just gives us declarative tools so the late parts can flow into specific holes punched into the early parts.</p>",
        "<p><a href=\"/04/posts\">&larr; back to posts</a></p>"
      ]
    }
  }
}

open on GitHub →

shell.html/app/src/examples/04-navigation-api/shell.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>04 · declarative partial updates</title>
  <link rel="stylesheet" href="/public/styles.css">
</head>
<body>
<main>
  <p class="crumbs"><a href="/">&larr; back to index</a></p>
  <h1>04. Navigation API + DPU</h1>
  <p class="lede">Links inside this demo are intercepted by <code>navigation.addEventListener('navigate')</code>, fetched as fragments, and streamed into <code>#content</code> via <code>streamHTMLUnsafe()</code>. URL and history both behave correctly. Without the API, full reloads work too because each route renders standalone.</p>
  <nav class="tabs">homepostsabout</nav>
  <section class="demo-area" id="content">

declarative partial updates

processing instructions plus <template for> let the server stream patches into a long-lived response.

← back to posts

</section> <script src="/04/client.js"></script> <section class="refs"> <h3>learn more</h3> <ul> <li><a href="https://developer.chrome.com/blog/declarative-partial-updates?hl=en" target="_blank" rel="noopener">Chrome blog: Declarative Partial Updates</a></li> <li><a href="https://developer.chrome.com/docs/web-platform/navigation-api" target="_blank" rel="noopener">Chrome docs: Navigation API</a> <span class="note">— Single <code>navigate</code> event, <code>event.intercept({ handler })</code>, signals, history.</span></li> <li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Navigation_API" target="_blank" rel="noopener">MDN: Navigation API</a></li> <li><a href="https://github.com/WICG/declarative-partial-updates/blob/main/dynamic-markup-revamped-explainer.md" target="_blank" rel="noopener">WICG explainer: dynamic markup revamped</a> <span class="note">— Why <code>streamHTMLUnsafe()</code> works as a sink for piped fetches.</span></li> </ul> </section> <!-- source-viewer goes here --> <footer class="byline">made by <a href="https://paul.kinlan.me/" target="_blank" rel="noopener">Paul Kinlan</a></footer> </main> </body> </html>

open on GitHub →

lib/streaming.ts/app/src/lib/streaming.ts
export class StreamAborted extends Error {
  constructor() {
    super("stream aborted by client");
    this.name = "StreamAborted";
  }
}

export type ChunkSource = (write: (chunk: string) => Promise<void>) => Promise<void>;

const encoder = new TextEncoder();

export function streamingResponse(
  source: ChunkSource,
  contentType = "text/html; charset=utf-8",
): Response {
  let aborted = false;
  const body = new ReadableStream<Uint8Array>({
    async start(controller) {
      const write = async (chunk: string) => {
        if (aborted) throw new StreamAborted();
        try {
          controller.enqueue(encoder.encode(chunk));
        } catch {
          aborted = true;
          throw new StreamAborted();
        }
      };
      try {
        await source(write);
      } catch (err) {
        if (!(err instanceof StreamAborted)) console.error("stream source error", err);
      } finally {
        if (!aborted) {
          try {
            controller.close();
          } catch {
            // already closed
          }
        }
      }
    },
    cancel() {
      aborted = true;
    },
  });
  return new Response(body, {
    headers: {
      "content-type": contentType,
      "cache-control": "no-store",
      "x-content-type-options": "nosniff",
    },
  });
}

export function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

open on GitHub →

lib/files.ts/app/src/lib/files.ts
// File helpers for examples: read sibling files, render the page shell with the
// source viewer injected, and serve sibling static assets (CSS/JS/HTML) on demand.

import { sourceBlock } from "./source.ts";

const MIME: Record<string, string> = {
  css: "text/css; charset=utf-8",
  js: "application/javascript; charset=utf-8",
  html: "text/html; charset=utf-8",
  svg: "image/svg+xml",
  json: "application/json; charset=utf-8",
};

function dirFor(handlerUrl: string): string {
  return new URL(".", handlerUrl).pathname;
}

export function readSibling(handlerUrl: string, name: string): string {
  return Deno.readTextFileSync(dirFor(handlerUrl) + name);
}

// Reads a sibling HTML file. The marker `<!-- source-viewer goes here -->` is replaced
// with the source-viewer block. Extra named placeholders ({{name}}) can be supplied via `vars`.
const SOURCE_MARKER = "<!-- source-viewer goes here -->";

export function loadShell(
  handlerUrl: string,
  name = "index.html",
  vars: Record<string, string> = {},
): string {
  let raw = readSibling(handlerUrl, name);
  raw = raw.replace(SOURCE_MARKER, sourceBlock(handlerUrl));
  for (const [k, v] of Object.entries(vars)) {
    raw = raw.replaceAll(`{{${k}}}`, v);
  }
  return raw;
}

// Try to serve a sibling static file from the example folder. Returns null when the
// file is missing, the path escapes the folder, or the path is empty.
export function tryStatic(handlerUrl: string, requestPath: string): Response | null {
  const safe = requestPath.replace(/^\/+/, "");
  if (!safe || safe.includes("..")) return null;
  // Never let the browser fetch handler.ts or README.md through this fallback.
  if (safe === "handler.ts" || safe === "README.md") return null;
  try {
    const content = Deno.readFileSync(dirFor(handlerUrl) + safe);
    const ext = safe.split(".").pop() ?? "";
    return new Response(content, {
      headers: {
        "content-type": MIME[ext] ?? "application/octet-stream",
        "cache-control": "no-store",
      },
    });
  } catch {
    return null;
  }
}

open on GitHub →