A teaching framework · every mechanism earns its place

The Softshell Log.

24
ENT
LIVING DOC

Softshell is a tiny, inspectable, Next-inspired framework built to learn how modern React app frameworks work underneath the polished developer experience. This page is the interactive companion to SOFTSHELL_LOG.md — same 24 chapters, with runnable demonstrations. The standing rule applies here too: before any implementation is explained, its reason to exist is motivated first.

KERNELROUTING LAYOUTSDATA INTERRUPTSSTREAMING HYDRATION

The request simulator

Every chapter · one pipeline

Everything softshell does is a change to one journey: a URL goes in, a Response comes out. Pick a request and watch the real pipeline decisions replay — matching, parallel loaders, framework signals, boundaries, and streaming. Each behavior below was added by a numbered chapter.

softshell · handle(request)GET → Response

001 · The request-to-render kernel

Why start here

The motivation. File-based routing is the visible part of Next.js, but it is not the deepest idea. The deeper idea is that a framework owns the journey from an incoming request to a delivered application. React, by itself, describes UI — it does not answer the full application question: someone requested this URL; what should happen now?

React is the renderer. Softshell is the runtime pipeline.— the whole project in one sentence

The kernel is the smallest useful framework idea, and everything later attaches to it:

Request -> match route -> render React -> create Response

Layouts attach before render. Data loading enriches the context. Caching wraps the work. Streaming changes how render produces the body. Hydration adds browser JavaScript to the shell. If we understand this kernel, every later feature has a place to land — skip it and the project becomes a pile of features with no spine.

002 · Web Request & Response

The framework boundary

The motivation. Two traps: build on Express/Fastify and you learn their request objects instead of framework internals; build inside a serverless platform and you add hosting concerns before the mental model is stable. The middle path — the core speaks the Web Fetch API:

src/core/handle.ts — the shape everything keys offhandle(request: Request): Promise<Response>
Node is how we run locally today. Request/Response is the framework boundary.

Route matching needs a URL. Rendering needs context. Headers fit Headers. Nothing framework-shaped is inherently Node-shaped — so the core uses the least surprising standard, and adapters translate at the edges. That one decision is why the serverless adapter (Entry 011) ends up nearly empty, and why streaming (Entry 022) later slots in without friction: a Web Response body was always allowed to be a stream.

In plain English Your laptop has one kind of power plug; every country's wall socket is different. You don't rebuild the laptop for each country — you buy a cheap plug adapter. Here the laptop is softshell's core (it understands exactly one shape of request), Node's HTTP server is one country's socket, and a serverless platform is another. The adapter files are the plugs. That's also why fetch.ts turned out nearly empty: that particular country happens to use the same plug the laptop was built with.

003 · The first files

Boundaries by construction

Day 1 shipped a deliberately tiny set: project config, a route module type, a route table, a render function, a core handler, a Node dev adapter, a serverless-shaped fetch adapter, and one route component. No bundler, no watcher, no hydration, no file routing — each was postponed until it became the concept being studied. (The bundler's day finally arrived in Entry 024.)

  • tsx runs TypeScript directly — no build pipeline before the runtime pipeline is understood
  • DOM libs in tsconfig — not for browser code, but because the core uses Web-standard Request/Response/ReadableStream types
  • dependencies: react, react-dom — that's all Day 1 needs

004 · The route module contract

What a route is

The motivation. A framework needs a way to talk about a route before it can render one. A route is not a file — a file is where a route comes from. A route is executable UI associated with a URL pattern. The type came before the discovery mechanism on purpose:

src/core/types.ts — grown by nearly every chapter sinceexport type RouteModule = {
  path: string;
  filePath: string;            // 024: so the client bundler can re-import it
  segments: RouteSegment[];    // 017: parsed pattern, not just a label
  layouts: LayoutModule[];     // 018: the shells that wrap the page
  component: ComponentType<RouteProps>;
  errorBoundary?: ErrorModule;      // 021
  notFoundBoundary?: NotFoundModule;// 021
  loadingBoundary?: LoadingModule;  // 022
  load?: RouteLoader;               // 019
  title?: string | ((ctx: RenderContext) => string | Promise<string>);
};

Components receive a prepared RenderContext — request, url, params — because routes should not parse requests from scratch. The framework normalizes raw request information; the app receives a clean interface.

005 · The route table

Matching before discovery

Before file-based routing, softshell had a hardcoded table and a matcher that was string equality. Less exciting than glob scanning — and exactly the point: the runtime path had to exist before file discovery became a way of generating the table automatically (Entry 016).

src/core/routes.ts — Day 1, the whole routing systemexport const routes: RouteModule[] = [
  {
    path: "/",
    component: Page,
    title: "Softshell",
  },
];

export function matchRoute(pathname: string): RouteModule | undefined {
  return routes.find((route) => route.path === pathname);
}
Given a URL pathname, find the route module that should handle it.

Watch what happens to these seven lines across the log: the array gets generated from the filesystem (016), find by equality becomes segment matching with params (017), and the list gains a specificity sort (017). The question never changes.

006 · The route component

Proving the renderer is real

Without a React component, the framework could return a hardcoded HTML string and prove only that we can write an HTTP server. The first page displays the current pathname from the render context — a small detail proving the request crossed the framework boundary into React. The page handles no sockets, writes no headers. It is just UI:

framework handles request mechanics React component describes UI

007 · The renderer

Element → document

Day 1 used renderToString: direct, buffered, perfect for the first lesson (Entry 022 replaces it with a stream). Three ideas hide in one line:

const appHtml = renderToString(createElement(route.component, { ctx }));

The framework creates the element (components don't render because they exist); request-derived information enters React through a controlled prop; react-dom turns the tree into markup. Then a document shell wraps it — browsers receive documents, not components. The shell is tiny but it is the right conceptual slot: metadata, stylesheets, client bundles, and hydration payloads all attach there in later chapters.

src/core/render.tsx (Day 1 shape) — the document shellfunction createDocument(input: { appHtml: string; title: string }): string {
  return `<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>${escapeHtml(input.title)}</title>
  </head>
  <body style="margin:0">
    <div id="root">${input.appHtml}</div>
  </body>
</html>`;
}

function escapeHtml(value: string): string {
  return value
    .replaceAll("&", "&amp;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll('"', "&quot;")
    .replaceAll("'", "&#39;");
}

That escapeHtml on the title is the first appearance of a recurring truth: once the framework owns document generation, it owns the safety boundaries. Titles can carry dynamic, user-influenced values (Entry 017's dynamic titles prove it: Blog: ${params.slug}); unescaped, a slug could inject markup into the head. Entry 024 applies the identical rule to hydration data.

In plain English Imagine a form that gets printed into a newspaper. If someone fills in their "name" with instructions to the printing press — and the press obeys them — anyone can hijack the paper. Escaping means telling the press: print these characters literally, never obey them. Softshell assembles the newspaper (the HTML document), so it's softshell's job to make sure nothing that came from outside is ever treated as an instruction.

008 · The core handler

The heart of the spine

One explicit function owns the application-level journey. Every future feature will be a change to this exact function, so it is worth reading in its original, complete Day-1 form:

src/core/handle.ts — Day 1, in full (compare with today's version in Entries 020–024)export async function handle(request: Request): Promise<Response> {
  const url = new URL(request.url);            // 1. parse
  const route = matchRoute(url.pathname);      // 2. match

  if (!route) {
    return new Response("Not found", {         // 3. own the failure path
      status: 404,
      headers: { "content-type": "text/plain; charset=utf-8" },
    });
  }

  const ctx: RenderContext = { request, url, params: {} };   // 4. context

  const html = await renderRoute(route, ctx);  // 5. render

  return new Response(html, {                  // 6. respond
    status: 200,
    headers: { "content-type": "text/html; charset=utf-8" },
  });
}

Step 3 deserves its own sentence: a framework is not just happy-path rendering; it owns failure paths too. That one plain-text 404 grows into an entire arc — Entry 020 gives it vocabulary, Entry 021 gives it UI, Entry 023 gives it subtree semantics.

Request -> URL -> route -> context -> render -> Response

009 · The Node adapter

Translating between worlds

Node's HTTP server speaks IncomingMessage/ServerResponse; the core wants Request/Response. The adapter bridges — including the stream boundary, where request bodies exist only for methods that carry one:

src/adapters/node.tsreturn new Request(url, {
  method,
  headers,
  body: hasBody ? Readable.toWeb(req) : undefined,   // GET/HEAD have no body
  duplex: hasBody ? "half" : undefined,
} as RequestInit);

The TypeScript casts at the stream boundary are exactly the kind of edge case that belongs in an adapter, not in the framework core. The core does not need to know Node exists.

010 · The dev server

Deliberately boring

The executable entrypoint contains no route or render logic — if it did, the core would be leaking outward. The whole architecture reads in one loop:

src/dev.tsconst server = createServer(async (req, res) => {
  const request = nodeRequestToWebRequest(req, origin);
  const response = await handle(request);
  await writeWebResponse(res, response);
});
Node request -> adapter -> softshell core -> adapter -> Node response

011 · The fetch adapter

Emptiness as proof

Many modern runtimes call a handler shaped fetch(Request) → Response. Because the core already has that shape, the serverless-facing adapter is almost nothing:

src/adapters/fetch.tsimport { handle } from "../core/handle.js";
export default { fetch: handle };
If this adapter had needed lots of code, Node had leaked too deep into the core.

012 · What this is not yet

Naming the gaps

The first working page creates false confidence unless the missing pieces are named. Day 1's gap list became the curriculum — and this page's later chapters are that list getting crossed off:

  • file-based routing → Entry 016
  • dynamic route params → Entry 017
  • layouts → Entry 018
  • streaming → Entry 022
  • hydration → Entry 024
  • Server/Client component split — future
  • caching — future
  • dev module graph / HMR — future

013 · The mental model

One durable picture

As the code grows more complicated, the model must stay simple:

The framework receives a request. The framework decides what work belongs to the server. The framework asks React to render UI. The framework returns a response the browser can use.

Everything else — all eleven chapters that follow — is an elaboration of those four lines.

014 · Why this teaches Next.js

One layer at a time

Next.js is productive precisely because it hides enormous machinery — which is what makes it hard to understand deeply. Creating one file in a Next app touches route trees, compiler graphs, server/client boundaries, payload formats, and deployment targets all at once. Softshell isolates one layer at a time, and each future feature gets motivated from the pain of the current implementation instead of arriving as magic.

015 · The next best step

Choosing by smallest pain

Adding a page meant editing the hardcoded table — fine for one route, un-framework-like for two. The chapter-selection principle used ever since: the next problem is the smallest one that naturally follows from what exists. Hence: a route manifest.

Developer writes files. Framework builds routing metadata. Runtime consumes routing metadata.

016 · File routing as manifest generation

Filesystem in · route table out

The motivation. Without file routing, adding an About page takes two steps: create the component, then remember to edit the framework's route table. That second step is the smell — the developer's intent is already present in the file they created.

The filesystem is developer input. The route manifest is framework data.
In plain English A restaurant doesn't sprint to the market every time you order — before opening, the chef walks the pantry once and writes the menu. Discovery is that pantry walk (slow, touches the disk); the route manifest is the menu (fast, in memory); requests only ever talk to the menu. Added a new dish — a new page.tsx? The menu gets rewritten at the next opening (server restart). The kitchen itself never changes.

Discovery is allowed to care about files; serving should care about routes. Discovery runs once at startup, producing matchable metadata the request world consumes:

src/app/page.tsx -> / src/app/about/page.tsx -> /about filesystem world -> route manifest -> request world

Discovery is three honest jobs: find the files, translate locations into URLs, and turn files into executable route metadata. The scanner is a plain recursive walk —

src/core/file-routes.ts — a route file is only a CANDIDATE, not yet a routeasync function findPageFiles(dir: string): Promise<string[]> {
  const entries = await readdir(dir, { withFileTypes: true });
  const files = await Promise.all(
    entries.map(async (entry) => {
      const fullPath = join(dir, entry.name);
      if (entry.isDirectory()) return findPageFiles(fullPath);
      return entry.isFile() && entry.name === "page.tsx" ? [fullPath] : [];
    }),
  );
  return files.flat();
}

— the path translation is where the convention lives (note the POSIX normalization: route paths must be URL-shaped even on filesystems with other separators) —

src/core/file-routes.ts — file location becomes URLfunction filePathToRoutePath(appDir: string, filePath: string): string {
  const routeFilePath = relative(appDir, filePath).split(sep).join(posix.sep);
  const routeDir = posix.dirname(routeFilePath);

  if (routeDir === ".") return "/";      // src/app/page.tsx is the root
  return `/${routeDir}`;
}

— and the import is the moment a filename becomes executable code, with the framework's first contract enforced loudly at startup instead of mysteriously at request time:

src/core/file-routes.ts — a file becomes a routeconst module = (await import(pathToFileURL(filePath).href)) as PageModule;

if (!module.default) {
  throw new Error(`${filePath} must default export a React component`);
}

return {
  path: routePath,
  segments: routePathToSegments(routePath),
  component: module.default,
  ...
};

The handler stopped importing a global table and became a factory — a closure that receives its routes. Setup work happens once; request-time work stays focused. A function that accepts dependencies beats a function that imports global state: tomorrow the manifest can come from a build step, a watcher, or a test, and handle never changes.

src/core/handle.tsexport function createHandler(routes: RouteManifest): RequestHandler {
  return async function handle(request: Request): Promise<Response> { ... };
}

017 · Dynamic segments

Named holes in a route

The motivation. /blog/hello-softshell and /blog/routing-internals are the same kind of page; only one segment varies. One file should serve them all:

src/app/blog/[slug]/page.tsx -> /blog/[slug] route path: /blog/[slug] <- a PATTERN, not a label request path: /blog/hello-softshell params: { slug: "hello-softshell" }
A dynamic segment is a named hole in a route pattern.
In plain English /blog/[slug] is a Mad Libs sentence: "show the blog post called ____." The brackets mark the blank. When /blog/hello-softshell arrives, the framework fills the blank and hands your page a sticky note: slug = "hello-softshell". And the specificity rule is just common sense written down: if you also wrote an exact sentence for /blog/new, the exact sentence beats the fill-in-the-blank.

The manifest evolved from "path as string" to "path plus parsed segments" — brackets are parsed once at discovery, never re-interpreted per request:

src/core/routes.tsexport function routePathToSegments(path: string): RouteSegment[] {
  return splitPathname(path).map((segment) => {
    const dynamicMatch = /^\[([A-Za-z0-9_]+)\]$/.exec(segment);
    if (dynamicMatch) return { kind: "dynamic", name: dynamicMatch[1] };
    return { kind: "static", value: segment };
  });
}

Matching now produces information. Comparing segment lists is a walk where static segments demand equality and dynamic segments capture — the first time the matcher's job is extraction, not just selection:

src/core/routes.ts — matchSegments, where params are bornfunction matchSegments(routeSegments, pathnameSegments): RouteParams | undefined {
  if (routeSegments.length !== pathnameSegments.length) return undefined;

  const params: RouteParams = {};

  for (const [index, routeSegment] of routeSegments.entries()) {
    const pathnameSegment = pathnameSegments[index];

    if (routeSegment.kind === "dynamic") {
      params[routeSegment.name] = decodeURIComponent(pathnameSegment);  // capture
      continue;
    }
    if (routeSegment.value !== pathnameSegment) return undefined;       // demand
  }

  return params;   // matching PRODUCED data: { route, params } flows to ctx
}

Two details worth noticing: segments are URL-decoded here (hello%20world arrives in the page as "hello world" — the framework normalizes raw request text before the app sees it), and pathnames are normalized before matching so /about/ and /about are the same route. Dynamic routes also created an ordering problem: /blog/new could fit /blog/[slug], so the manifest is sorted once at discovery:

src/core/routes.ts — sortRoutesBySpecificityreturn [...routes].sort((left, right) => {
  const lengthDifference = right.segments.length - left.segments.length;
  if (lengthDifference !== 0) return lengthDifference;      // more segments first

  const staticDifference =
    countStaticSegments(right.segments) - countStaticSegments(left.segments);
  if (staticDifference !== 0) return staticDifference;      // static beats dynamic

  return left.path.localeCompare(right.path);               // stable tie-break
});

Once routes are patterns, match order matters — the literal route always beats the pattern that could swallow it.

018 · Layouts as shared shells

Tree-based routing

The motivation. Real apps want shared UI around pages — the same nav on every page, the same section chrome on every blog page. Without layouts, every page repeats it, and ownership blurs: does the page own the whole screen, or only the leaf content?

A route is not just a destination. It is a path through a component tree.
In plain English Think nesting dolls, where each doll paints one ring of the picture: the outermost doll is the site header, the next is the blog section banner, the innermost is the actual article. A URL doesn't pick one doll — it picks a path through the whole nest. That's why /about and /blog/x share the outer doll (same header) but only blog pages sit inside the blog doll.
/blog/hello-softshell src/app/layout.tsx <- wraps every route src/app/blog/layout.tsx <- wraps only the blog subtree src/app/blog/[slug]/page.tsx Layouts are inherited downward, not sideways.

What makes wrapping possible at all is one prop. A page receives the route context; a layout receives the context plus whatever route content belongs inside it:

src/core/types.ts — children is the whole trickexport type LayoutProps = {
  ctx: RenderContext;
  children: ReactNode;   // the wrapped subtree — page, or another layout
};

In React terms a layout is not special — just a component rendering children. In framework terms it is special because softshell decides which layouts wrap which page, and that decision is filesystem interpretation — so it belongs to discovery, not to the request handler. For each page, walk root → page directory and keep the layout.tsx files that exist:

src/core/file-routes.ts — the chain of directories a page lives underfunction directoriesFromAppRoot(appDir: string, routeDir: string): string[] {
  const routeDirPath = relative(appDir, routeDir);
  if (!routeDirPath) return [appDir];

  const directories = [appDir];
  let currentDir = appDir;

  for (const segment of routeDirPath.split(sep)) {
    currentDir = join(currentDir, segment);
    directories.push(currentDir);      // root -> … -> page dir, in order
  }
  return directories;
}

// for src/app/blog/[slug]/page.tsx this checks, in order:
//   src/app/layout.tsx          ✓ exists -> kept
//   src/app/blog/layout.tsx     ✓ exists -> kept
//   src/app/blog/[slug]/layout.tsx  ✗ doesn't -> skipped

The manifest stores that chain parent → child (natural reading order); the renderer builds the tree leaf-first, so a single .reverse() puts the root layout outermost:

src/core/render.tsx — the wrap that everything later reuseslet element = createElement(route.component, { ctx, data });
for (const layout of [...route.layouts].reverse()) {
  element = createElement(layout.component, { ctx, children: element });
}
// RootLayout( BlogLayout( BlogPostPage ) )

The rendered result for /blog/hello-softshell shows three regions from three files on one screen — root nav, blog heading, post content. That is the feature. And it is the App Router's biggest idea: not file-based routing, tree-based routing.

019 · Route-level data loading

Params become inputs

The motivation. A blog page doesn't want to render a slug; it wants the post the slug points to. The slug is a lookup key, not content. Without a framework convention, every page invents its own data story. The pipeline gained a slot — after matching, before rendering:

match route -> extract params -> run load(ctx) -> render page
src/app/blog/[slug]/page.tsxexport async function load({ params }: RouteProps["ctx"]) {
  return { post: getPost(params.slug) };
}
// the page receives it as a prop:
export default function BlogPostPage({ ctx, data }: RouteProps<BlogPostData>) { ... }
The framework controls the request lifecycle. The app declares what data a route needs. The framework runs that declaration before rendering.
In plain English A page is a chef who plates food; a loader is the runner who fetches the ingredients first. The house rule: before the chef is asked to plate (render), the framework sends the route's runner (load) and hands the chef whatever comes back as a prop. The chef never goes shopping mid-plating — that's exactly what keeps pages simple.

Softshell doesn't care what the loader does — file read, database, API. The contract is only: if the route exports load, call it with ctx; the return value becomes page data. On the wire that contract is two lines in the handler and one widened prop type:

src/core/handle.ts + src/core/types.ts — the entire v1 mechanism// the handler owns the pipeline, so it runs the route's request-specific work:
const data = route.load ? await route.load(ctx) : undefined;
const html = await renderRoute(route, ctx, data);

// and the page's contract gains a second prop:
export type RouteProps<TData = unknown> = {
  ctx: RenderContext;   // framework context: request, url, params — always there
  data: TData;          // route-specific app data — whatever load() returned
};

The split between those two props matters: ctx is supplied by softshell for every route; data is supplied by this route's declaration. Pages without loaders get undefined and stay simple. Deliberately absent in v1, each with its future chapter: layout loaders (023), caching, typed inference from load's return type to the page's data prop. Once this slot existed, the real framework questions became askable: what if it throws? can layouts load too? can data stream in late? Entries 020–023 are those questions, answered.

020 · Framework interrupts

notFound() · redirect()

The motivation. Data loading created a second kind of failure. /blog matches no route — a plain 404. But /blog/missing-post matches /blog/[slug]; the loader runs; only then does the app discover the resource doesn't exist. Different failure modes deserve different vocabulary:

A matched route can decide not to render.
src/app/blog/[slug]/page.tsx — the loader speaks framework verbsif (requestedSlug === "latest") {
  redirect("/blog/hello-softshell");   // -> 307 + Location, no render
}
const post = getPost(requestedSlug);
if (!post) {
  notFound();                          // -> 404, no render
}
post.title;   // TypeScript allows this: notFound() returns `never`

Both helpers throw — and that's the deep lesson. Why throwing, and not returning a special object? Because a return value must be inspected by every caller up the chain, and the page would have to stay typed as "post might be missing" even after the loader already decided. A throw escapes the entire nested call stack in one move, and the framework catches it at the one place that owns responses:

Not all thrown values are crashes. Some are control-flow signals owned by the framework.
In plain English throw usually means "something broke." Here it's used like a fire alarm: pulling it isn't a malfunction — it's the building's built-in way to stop everything immediately, from any floor, without asking permission level by level. notFound() and redirect() pull that alarm. The front desk (the handler) recognizes those two specific alarm tones and responds calmly — a 404 page, a redirect. Any tone it doesn't recognize is treated as an actual fire: a real bug, a real 500.
src/core/navigation.ts — the whole moduleexport class NotFoundSignal extends Error {
  readonly kind = "notFound";
  constructor() { super("Softshell notFound()"); }
}

export class RedirectSignal extends Error {
  readonly kind = "redirect";
  constructor(
    readonly location: string,
    readonly status: RedirectStatus = 307,
  ) { super(`Softshell redirect(${location})`); }
}

export function notFound(): never {
  throw new NotFoundSignal();
}

export function redirect(location: string, status: RedirectStatus = 307): never {
  throw new RedirectSignal(location, status);
}

export function isNavigationSignal(error: unknown): error is NavigationSignal {
  return error instanceof NotFoundSignal || error instanceof RedirectSignal;
}

Note the deliberate privacy: the signal classes exist, but app code never constructs them — it says what it wants (notFound()) and the framework owns the mechanism. The never return type is doing real work too: TypeScript knows that branch cannot return, so after if (!post) notFound(); the compiler narrows post to defined — a thrown signal became type information. The catch side is one discriminating question:

src/core/handle.ts — the line between signal and bug} catch (error) {
  // Framework-owned control flow signals are not bugs.
  if (isNavigationSignal(error)) {
    if (error instanceof RedirectSignal) {
      return redirectResponse(error);       // -> 3xx + Location header
    }
    return renderNotFoundResponse(...);     // -> 404
  }
  // Anything else is an unexpected failure — a REAL bug. It must not be
  // swallowed by the framework's own catch.
  return renderErrorResponse(...);          // -> 500 (Entry 021)
}

A redirect is an HTTP decision, not a render result — the response is status: 307, headers: { location } with no body at all. Try /blog/latest and /blog/missing-post in the simulator and watch the two signals take different exits.

021 · Component-level fallbacks

error.tsx · not-found.tsx

The motivation. Entry 020 could decide not to render, but couldn't render the decision — 404s were plain text, and unexpected bugs fell through to a generic 500. The nav and chrome that wrapped every healthy page simply vanished. But because the route matched, the framework already knows the layout chain, so it can keep the surviving shells and replace only the failed leaf:

RootLayout RootLayout BlogLayout => BlogLayout BlogPostPage (fails) ErrorBoundary <- 500, chrome intact
The framework knows the route tree, so it can replace the smallest failed region.
In plain English An understudy in theater: when the lead collapses mid-show, you don't evacuate the building — the understudy steps into that one role and the show continues around them. error.tsx is the page's understudy. Because the framework has the full cast list (the layout chain), it keeps everyone else on stage and swaps only the actor who failed. "Nearest boundary wins" just means each part of the theater keeps its own understudy in the wings, and the closest one goes on.

Two files, one machinery — a notFound() signal renders the nearest not-found.tsx (404); any unexpected throw renders the nearest error.tsx (500), receiving the Error as a prop. Boundaries differ from layouts in a crucial way, and the difference is visible as two different directory walks:

layouts: a page inherits ALL ancestors (they nest) boundaries: a page is caught by the NEAREST one (walk up, first wins)
src/core/file-routes.ts — nearest-boundary walk (vs. the collect-all layout walk)async function findNearestBoundaryForPage(appDir, pageFilePath, filename) {
  const directories = directoriesFromAppRoot(appDir, dirname(pageFilePath));

  // directoriesFromAppRoot is ordered root -> page, so iterate from the END
  // to reach the page's closest ancestor boundary first.
  for (let index = directories.length - 1; index >= 0; index -= 1) {
    const candidate = join(directories[index], filename);
    if (await fileExists(candidate)) return candidate;
  }
  return undefined;
}

The render side reuses Entry 018's layout wrap wholesale — a fallback is just a different leaf riding the same chain. That generalization (wrap any leaf, not only the page) is the entire refactor this chapter required of the renderer:

src/core/render.tsx — renderFallbackDocumentexport function renderFallbackDocument(input: {
  layouts: LayoutModule[];
  layoutData: unknown[];
  ctx: RenderContext;
  leaf: ReactElement;        // an error OR not-found component — any leaf
  title: string;
}): string {
  const appHtml = renderToString(
    wrapWithLayouts(input.layouts, input.layoutData, input.ctx, input.leaf),
  );
  return documentPrefix(input.title) + appHtml + DOCUMENT_SUFFIX;
}

And the handler branch, including the trap most first implementations miss — what if the fallback itself throws? Rendering an error boundary for the error boundary would loop forever; the framework must bottom out at plain text. Softshell's escape hatch was verified by deliberately breaking error.tsx and watching /blog/boom degrade to a text 500 instead of hanging:

src/core/handle.ts — renderErrorResponse, with the escape hatchfunction renderErrorResponse(route, ctx, cause, layouts, layoutData): Response {
  const boundary = route.errorBoundary;
  const error = toError(cause);

  if (!boundary) {
    throw error;   // no boundary: rethrow so the raw error still surfaces as a 500
  }

  try {
    const html = renderFallbackDocument({
      layouts, layoutData, ctx,
      leaf: createElement(boundary.component, { ctx, error }),
      title: "Something went wrong",
    });
    return new Response(html, { status: 500, headers: HTML_HEADERS });
  } catch {
    // Escape hatch: if the boundary (or a surviving layout) throws while
    // rendering the fallback, degrade to plain text instead of looping.
    return new Response("Internal Server Error", {
      status: 500,
      headers: TEXT_HEADERS,
    });
  }
}

022 · Streaming, Suspense & loading.tsx

The response is a stream
§0 The hostage problem

The motivation. Look at what renderToString actually is: a function that cannot return until the entire tree has rendered. The header finished in a microsecond. The nav finished. The layouts finished. And all of it sits in a server-side string, going nowhere, because one stats query needs 1.2 more seconds. The user stares at a blank tab — not because the server is slow, but because the slowest part of the page holds every finished part hostage:

renderToString: [========== render EVERYTHING ==========][send] user sees: nothing … then all streaming: [render shell][send]───[send late piece]───[send late piece] 4ms ↑ user sees the page ↑ slow parts fill in

The fix required no new transport. An HTTP body was never required to be a single buffer — it is bytes over time. And softshell's core has spoken Web Response since Entry 002, whose body may be a ReadableStream. The boundary drawn on Day 1 was already streaming-shaped; only the renderer wasn't.

§1 What "suspending" actually is
In plain English You order five dishes. Four are ready instantly; the soufflé needs twenty minutes. A bad waiter makes you stare at an empty table until everything is done. A good waiter brings the four dishes now, plus a little "soufflé is coming" card holding its spot. use(promise) is a dish announcing "not ready yet"; the <Suspense fallback> is the placeholder card; streaming is the waiter's second trip to the same table — same response, just later.

Streaming only matters if something can say "I'm not ready." Mechanically, that is all Suspense is: a component, mid-render, hands React a promise instead of returning UI. In React 19 the verb is use():

src/app/blog/[slug]/page.tsx — a component that suspendsfunction StatsPanel({ stats }: { stats: Promise<PostStats> }) {
  const resolved = use(stats);   // pending? -> React SUSPENDS this subtree
  return (
    <div>
      <span>{resolved.views.toLocaleString()} plays</span>
      ...
    </div>
  );
}

// and in the page body, the boundary that catches it:
<Suspense fallback={<StatsSkeleton />}>
  <StatsPanel stats={data.stats} />
</Suspense>

When use() meets a pending promise, React abandons that subtree's render, walks up to the nearest <Suspense>, renders its fallback instead, and subscribes to the promise. Everything outside the boundary keeps rendering. When the promise settles, React re-renders just the abandoned subtree — and on the server, ships the result as a late chunk into the same response (§3). Where does a pending promise come from? The loader stops awaiting things on purpose:

src/app/blog/[slug]/page.tsx + src/app/slow/page.tsx — the deferring pattern// blog: post blocks, stats deliberately do not
return {
  post,                                  // awaited chain: getPost() already ran
  stats: loadPostStats(requestedSlug),   // a promise, NOT awaited — 1.2s away
};

// slow: the WHOLE page defers
export function load(): SlowPageData {
  return {
    report: new Promise((resolve) => {
      setTimeout(() => resolve({ batches: 7, unitsPacked: 2140, ... }), 2000);
    }),
  };
}
export default function SlowPage({ ctx, data }: RouteProps<SlowPageData>) {
  const report = use(data.report);   // top-level suspension: loading.tsx holds the spot
  ...
}

This quietly redefined the loader. Its question is no longer "what data does this page need?" but "which data must block the first byte, and which may arrive later?" Await it in load() = blocking. Return the raw promise = deferred. That split is a real API decision the app author makes per field.

§2 The status-code deadline
The status code ships with the first byte. Everything that can change it must happen before anything is sent.
In plain English An HTTP response opens with its verdict — 200, 404, 500 — like the stamp on the first page of a letter. The instant the courier leaves with page one, the stamp is final; pages two and three can't change it, no matter what they say. So every question that could change the verdict (does this post exist? should you be redirected?) has to be answered before page one is handed over. That's the entire reason loaders run before rendering, and it's the wall streaming can never break.

HTTP writes the status line first — HTTP/1.1 200 OK — before any body bytes. Once the first chunk leaves, the number is spoken for. This single constraint explains the pipeline's entire ordering, including why loaders that can notFound() are awaited rather than deferred:

match route -> can still 404 run load(ctx) -> can still notFound() / redirect() / 500 render the SHELL -> can still fail to a boundary --- first byte leaves; the 200 is committed --- stream suspended content -> can no longer change anything above

React's API is built around exactly this moment. renderToReadableStream returns a promise of a stream, and that promise resolves when the shell — everything outside Suspense boundaries — has rendered:

src/core/render.tsx — the shell contract// This promise resolves when the SHELL is ready. If the shell itself throws
// (a layout, or a page with no loading boundary), the promise REJECTS and the
// handler can still pick a real status code — no bytes have been sent yet.
const appStream = await renderToReadableStream(element, {
  onError(error) {
    // Errors inside an already-flushed Suspense boundary land here. The
    // status code is spoken for by then, so all we can do is log.
    console.error("softshell: error while streaming", error);
  },
});

That one await is why Entry 021's boundaries survived streaming untouched: a shell failure rejects into the handler's existing catch, and the error page goes out with a genuine 500. The two onError comments are the deadline stated twice — before the first byte, errors choose status codes; after it, they can only be logged.

§3 What React actually streams

View-source on /slow while it loads and the whole trick is legible. The shell arrives with the boundary rendered as its fallback, wrapped in markers:

view-source, first flush (~4ms) — the boundary is a fallback + a promise of more<!--$?--><template id="B:0"></template>
  <!-- loading.tsx skeleton HTML: the stamp, the bars … -->
<!--/$-->

Two seconds later, into the same open response, React emits the real content — hidden — plus a one-line script that swaps it in:

view-source, late chunk (~2.0s) — content + the swap<div hidden id="S:0">
  <!-- the real page: "This page took two seconds. The shell didn't." … -->
</div>
<script>$RC("B:0","S:0")</script>

$RC ("replace content") is a few hundred bytes of vanilla JavaScript React inlines once per response: find the template's boundary, remove the fallback, move the hidden nodes into place. No bundle, no hydration, no framework runtime — the swap works on a page with zero client JavaScript of its own, which is what made this chapter buildable a full chapter before hydration existed. The measured result:

/slow first byte: 0.004s total: 2.02s status: 200 /blog/hello-softshell first byte: 0.002s total: 1.20s status: 200 (before this entry, first byte EQUALED total)
§4 The envelope — a shell that streams

Entry 007's document shell was a template string around a finished app string. It could not survive contact with a stream — there is no finished string to wrap. It became a streaming envelope: emit the head immediately, pump React's chunks through untouched, close the document when React's stream ends:

src/core/render.tsx — wrapAppStreamInDocument, the pumpreturn new ReadableStream<Uint8Array>({
  async start(controller) {
    controller.enqueue(encoder.encode(documentPrefix(input.title, ...)));

    const reader = input.appStream.getReader();
    for (;;) {
      const { done, value } = await reader.read();
      if (done) break;
      controller.enqueue(value);          // React's chunks pass through untouched
    }

    controller.enqueue(encoder.encode(ROOT_CLOSE));      // </div>
    // (Entry 024 later slots the hydration payload exactly here)
    controller.enqueue(encoder.encode(DOCUMENT_TAIL));   // </body></html>
  },
});

Buffered rendering did not disappear — it retreated to where it is correct. renderFallbackDocument (error/not-found pages) still uses renderToString: fallbacks are small, and their status codes must precede their bytes anyway.

§5 loading.tsx — a file convention in six lines

With all the machinery above in place, the marquee feature costs almost nothing. Discovery finds the nearest loading.tsx with Entry 021's boundary walk — literally the same function, third filename:

src/core/file-routes.ts — one added line of discoveryconst [layouts, errorBoundary, notFoundBoundary, loadingBoundary] =
  await Promise.all([
    discoverLayoutsForPage(appDir, filePath),
    discoverBoundary<ErrorProps>(appDir, filePath, "error.tsx"),
    discoverBoundary<NotFoundProps>(appDir, filePath, "not-found.tsx"),
    discoverBoundary<LoadingProps>(appDir, filePath, "loading.tsx"),   // new
  ]);

…and the renderer wraps the page leaf in a framework-owned boundary. When Next's docs say "loading.tsx automatically wraps your page in Suspense," this is the literal, complete mechanism:

src/core/render.tsx — the entire featurelet leaf: ReactElement = createElement(route.component, { ctx, data });

if (route.loadingBoundary) {
  leaf = createElement(Suspense, {
    fallback: createElement(route.loadingBoundary.component, { ctx }),
    children: leaf,
  });
}

Layouts sit outside that boundary — so they stream immediately while the page streams in behind its skeleton. Pages keep declaring inner boundaries by hand for finer grain, exactly like the blog's stats panel in §1.

Streaming, replayed/slow · measured: ttfb 0.004s, total 2.02s

Press play: the shell arrives in milliseconds, the skeleton holds the page's spot, and two seconds later React streams the real content into the same response.

0s · first byte0.00s2.0s · stream closes
— press play —
§6 The honest walls
  • Post-shell errors can't change the status. An error inside an already-flushed boundary lands in onError, gets logged, and the client keeps the fallback. Every streaming framework hits this wall; there is no fix, only the discipline of putting status-deciding work before the shell.
  • Signals lose power inside boundaries. A notFound() thrown from a component inside Suspense after first flush is just another post-shell error — which is why softshell keeps interrupts in loaders, where they run before the deadline.
  • The pump ignores backpressure. The envelope pushes chunks as fast as React produces them instead of honoring pull(). Fine at teaching scale; a production pump would respect the consumer.
§7 Many boundaries, one ignition

Give a page several boundaries on different clocks — say related posts (0.5s), stats (1.2s), comments (2.5s) — and the wire teaches two lessons at once:

~4ms shell + ALL fallbacks (fallbacks are part of the shell) 0.5s related posts content <- LAST in the document, FIRST to arrive 1.2s stats content chunks come in COMPLETION order, 2.5s comments + payload + not document order

Completion order is safe because every swap targets its boundary by id — $RC("B:2","S:2") doesn't care who finished first. Three ovens, one waiter, dishes served as they're done. The second lesson is about hydration: in softshell there is no per-boundary hydration schedule. The payload is the trailer, so the module can't execute until the stream closes — meaning the page looks progressively done but becomes alive all at once, at the end. Every click before that moment hits markup with no listeners and dies — even on shell UI whose markup existed from millisecond four.

That dead-click window is exactly what React's selective hydration exists to fix: hydrate the shell while boundaries still stream, hydrate each island as its chunk lands, and if the user clicks a not-yet-hydrated island, prioritize it and replay the click. Softshell can't do any of that, and the blocker is our own wire format — the trailer requires the whole stream before the client knows its props. Simple protocol, late interactivity: a priced trade-off, and a candidate future chapter.

023 · Layout data & subtree failure

Segments own their data

The motivation. The blog layout wants the archive's post count — layout-owned data that belongs to every page under /blog. Without layout loaders the options were repetition (each page fetches it) or buried I/O (the layout reaches into data during render, invisible to the framework). Same move as Entry 019, one level up: layouts export load(ctx).

src/core/handle.ts — parallel, not waterfallconst [layoutOutcomes, pageOutcome] = await Promise.all([
  Promise.all(route.layouts.map((layout) => runLoader(layout.load, ctx))),
  runLoader(route.load, ctx),
]);

Parallelism is safe by construction: every loader receives only ctx, never another loader's result — the chain shares a request, not a data pipeline. But naive Promise.all has a flaw for this job: it rejects on the first failure and throws away the information of which one failed and what the others returned. So every loader runs through a wrapper that converts throws into values:

src/core/handle.ts — outcomes, not exceptions// Every loader (layout or page) either produced data or captured its throw.
// Capturing keeps two properties: all loaders run to completion in parallel,
// and we know exactly WHICH one failed.
type LoaderOutcome =
  | { ok: true; data: unknown }
  | { ok: false; error: unknown };

async function runLoader(load, ctx): Promise<LoaderOutcome> {
  if (!load) return { ok: true, data: undefined };
  try {
    return { ok: true, data: await load(ctx) };
  } catch (error) {
    return { ok: false, error };
  }
}

Knowing which segment failed is what enables the failure semantics:

A failed layout poisons its subtree. The fallback renders inside only the shells above the failure.
In plain English The blog layout is scaffolding: every blog page physically hangs from it. If the scaffolding itself gives way, it makes no sense to show things that were hanging from it — they have nothing to hang from. So the framework keeps only the floors above the break (the site header), and posts the error notice at the exact height where the structure gave out. That's all "poisoning the subtree" means: a failure takes down what depended on it, and nothing more.
page loader fails: blog LAYOUT loader fails: RootLayout RootLayout <- only survivor BlogLayout (data intact) ErrorBoundary <- blog shell GONE ErrorBoundary

The truncation itself is almost anticlimactic — the outermost failure wins, and one slice() expresses "everything below it could never have rendered":

src/core/handle.ts — the subtree cutconst failedLayoutIndex = layoutOutcomes.findIndex((outcome) => !outcome.ok);

if (failedLayoutIndex !== -1) {
  const failed = layoutOutcomes[failedLayoutIndex] as { ok: false; error: unknown };

  return failureToResponse(
    route,
    ctx,
    failed.error,
    route.layouts.slice(0, failedLayoutIndex),                      // surviving shells
    layoutOutcomes.slice(0, failedLayoutIndex).map(outcomeData),    // …and their data
  );
}

The demo layout carries both the data and the trap in one loader:

src/app/blog/layout.tsx — a layout that loads (and can be detonated)export function load({ url }: RenderContext): BlogLayoutData {
  // A deliberate trap: a layout loader failure poisons the whole blog
  // subtree. Try /blog/hello-softshell?break-layout=1
  if (url.searchParams.has("break-layout")) {
    throw new Error("Intentional explosion in the blog layout loader…");
  }

  return {
    postCount: listPosts().length,
    archiveLabel: "cassette archive",
  };
}

export default function BlogLayout({ data, children }: LayoutProps<BlogLayoutData>) {
  // data belongs to THIS layout — the page never sees it
  ...
}

Try ?break-layout=1 in the simulator. A correctness fix fell out for free: fallback documents now receive surviving layouts' data — under Entry 021 they re-rendered layouts data-blind, harmless then, a crash once the blog layout actually read data.postCount. The 404 page still shows the post-count chip because that loader genuinely succeeded on that request.

024 · Hydration — the browser runtime

The first bundle
§0 The dead button, dissected

The motivation, stated as bluntly as possible: put an onClick anywhere in the app and nothing happens. Here is the actual component that now lives on the home page:

src/app/page.tsx — HydrationCounterfunction HydrationCounter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <button
        type="button"
        onClick={() => setCount((current) => current + 1)}
      >
        Press me
      </button>
      <span>
        pressed {count} times ·{" "}
        {count > 0
          ? "this page is hydrated and alive"
          : "dead until the client bundle hydrates"}
      </span>
    </div>
  );
}

Before this chapter, the server rendered it happily. useState works in a server render — it returns the initial value; there is no update loop because a server render is a single pass, so setCount is created and simply never called. Then renderToReadableStream serialized the tree, and this — exactly this — is what reached the browser:

view-source: what the wire actually carried<button type="button" style="border:1.5px solid #3A2516;...">Press me</button>
<span style="...">pressed 0 times · dead until the client bundle hydrates</span>

Look at what is missing. No onclick attribute. No reference to a handler. Nothing. And this is not React being lazy — it is a fact about the medium. onClick={() => setCount(...)} is a closure: a function object in the server's memory holding a reference to a state cell in the server's memory. HTML is text. There is no HTML syntax that can represent "a function, closed over live memory, on another machine." (React could emit onclick="..." strings, but a string can't close over setCount — that whole idea collapses immediately.) Markup can carry the result of behavior, never behavior itself. Until this chapter, softshell was a very elegant HTML printer.

The server HTML and the browser app are the same component tree, rendered twice — once to markup, once to behavior.
In plain English Server HTML is a photograph of an app. A photo can show a button mid-press, but you can't press a photograph. onClick is behavior — a living function — and paper can't hold living things. Hydration ships the actual app to the browser and has it stand exactly where its photograph was, perfectly aligned, so the picture appears to come alive without visibly changing.

Why "rendered twice" and not "shipped once"? Because the click handler must live in browser memory as a real closure over a real state cell. The only way to get it there is to run the code that creates it — HydrationCounter itself — in the browser. And if the browser is going to re-run components anyway, running them with the same props means React computes the same tree the server did — and hydrateRoot can then adopt the DOM that already exists instead of rebuilding it. No pixels change; the page switches from printed to alive. That single principle dictates the entire chapter's shopping list:

1. the same components -> the browser needs the CODE -> a bundle (§1–§2) 2. the same props -> the browser needs the DATA -> a payload (§3) 3. the same tree shape -> the browser needs the ASSEMBLY -> a runtime (§4)
§1 Why a bundler exists at all
In plain English Your page file says "also grab react"; react says "also grab scheduler" — hundreds of also-grabs deep, written in dialects browsers don't speak (TypeScript, JSX), pointing at shelves browsers can't reach (node_modules, your disk). A bundler is a packing service: it follows every also-grab note, translates everything to plain JavaScript, and packs the lot into one box the browser downloads once. And "the entry nobody writes" is simply the packing list — softshell generates it from the route manifest, so creating a page file remains the only thing a developer ever does.

Requirement one: the browser needs HydrationCounter. That function lives in src/app/page.tsx. So the naive plan is: serve that file and let the browser import it. Entry 003 postponed bundling "until bundling is the concept we are actually studying" — this is that day, so let's actually study it: walk the naive plan into every wall it hits, because a bundler is nothing more than the machine that removes these four walls.

Wall 1 — the browser cannot read our disk. The dev server serves responses, not a filesystem. Before anything else, every module the page needs must be reachable over HTTP. Fine — we could add a static route per source file. Keep going.

Wall 2 — bare specifiers. Look at the first line of the page module:

src/app/page.tsx, line 1 — legal in Node, illegal in a browserimport { useState } from "react";
// Browser: TypeError — Failed to resolve module specifier "react".
// Relative references must start with "/", "./", or "../".

"react" is a bare specifier. Node resolves it with an algorithm: walk up the directory tree probing node_modules/react, read its package.json, interpret the "exports" map, pick a file. Browsers implement none of that — a browser import must be a URL. Something has to resolve every bare specifier to a real file before the browser ever sees the code.

Wall 3 — the source is not JavaScript. Even resolved, the file can't execute: browsers speak neither TypeScript nor JSX. Types can be erased, but JSX is a real syntax transform — what you write is not what runs:

the jsx transform — what "<button onClick={...}>" actually is// what you wrote:
<button onClick={() => setCount((c) => c + 1)}>Press me</button>

// what must execute (jsx: "automatic" targets react/jsx-runtime):
import { jsx } from "react/jsx-runtime";
jsx("button", { onClick: () => setCount((c) => c + 1), children: "Press me" });

Wall 4 — the module graph fans out. page.tsx imports react; hydration needs react-dom/client; react-dom imports scheduler; the blog page imports posts.ts and navigation.ts… Loading modules natively means the browser discovers each import only after downloading its importer — a waterfall of serial round trips, hundreds deep for React alone.

A bundler is a compiler over the import graph: start at an entry, resolve every specifier the way Node would, transform each file to plain JavaScript, and flatten the whole graph into one file.

When does it run? At build time — for softshell, once at dev-server startup, exactly like route discovery and for exactly the same reason (Entry 016: interpret the filesystem before requests arrive, never during them). Requests are served from memory. This is the same phase boundary the manifest taught, applied to code instead of metadata:

STARTUP (filesystem world) REQUEST TIME (request world) discoverRoutes() -> RouteManifest -> createHandler(routes) buildClientBundle() -> bundle string -> served at /_softshell/client.js

Softshell uses esbuild — one function call, no config file. Every option below is one of the four walls being removed:

src/core/client-bundle.ts — the entire bundler integrationconst result = await build({
  stdin: {
    contents: entrySource,        // the entry is a STRING — see §2; never touches disk
    resolveDir: process.cwd(),    // where bare-specifier resolution starts (Wall 2)
    loader: "ts",
    sourcefile: "softshell-client-entry.ts",
  },
  bundle: true,                   // follow the whole import graph (Walls 1 + 4)
  write: false,                   // keep the output in memory; the kernel serves it
  format: "esm",
  platform: "browser",            // pick "browser" fields when packages offer both
  jsx: "automatic",               // the transform from Wall 3, targeting react/jsx-runtime
  define: { "process.env.NODE_ENV": '"development"' },
  logLevel: "silent",
});

return result.outputFiles[0].text;

Two lines deserve extra attention. define exists because React's own entry point is written for Node:

node_modules/react-dom/index.js (paraphrased) — why `define` is not optionalif (process.env.NODE_ENV === "production") {
  module.exports = require("./cjs/react-dom.production.js");
} else {
  module.exports = require("./cjs/react-dom.development.js");
}
// Browsers have no `process` — this line would throw at runtime.
// `define` textually replaces the expression at BUILD time, so the
// condition becomes "development" === "production": statically false,
// and the dead branch is eliminated from the bundle entirely.

And one resolution wrinkle: softshell's imports end in .js (import ... from "../core/types.js") even though the files are .ts — the NodeNext convention from Entry 003. esbuild mirrors TypeScript's rule: when ./types.js doesn't exist, it tries ./types.ts. Without that, not one file in the repo would bundle.

§2 The entry nobody writes

esbuild needs an entry point. What is it? It can't be a single page — the bundle is built once but must serve every route. So the entry must import all pages and layouts and expose them keyed by route. The tempting shortcut is a hand-written registry file… and that is Entry 016's smell in a new costume: create page.tsx, then remember to also register it for the client. The developer's intent is already in the filesystem; the framework already extracts it — discoverRoutes(). So the entry is generated from the manifest. The only missing ingredient was that discovery threw file paths away after importing modules; now every module type records where it came from:

src/core/types.ts — the one-field change that makes generation possibleexport type RouteModule = {
  path: string;
  // Absolute path of the page source file. Discovery keeps it so the client
  // bundler can re-import the same module for the browser.
  filePath: string;
  ...
};

The generator walks the manifest, emits one import per distinct file (the root layout appears in every route's chain but must be imported once — same module identity matters for React), and builds the registry:

src/core/client-bundle.ts — generateEntrySource, the dedup coreconst identifiers = new Map<string, string>();

const identifierFor = (filePath: string): string => {
  let id = identifiers.get(filePath);
  if (!id) {
    id = `m${identifiers.size}`;
    identifiers.set(filePath, id);
    imports.push(`import ${id} from ${JSON.stringify(filePath)};`);
  }
  return id;
};

for (const route of routes) {
  const componentId = identifierFor(route.filePath);
  const layoutIds = route.layouts.map((l) => identifierFor(l.filePath));
  const loadingId = route.loadingBoundary
    ? identifierFor(route.loadingBoundary.filePath)
    : "undefined";

  registryEntries.push(
    `  ${JSON.stringify(route.path)}: { component: ${componentId}, ` +
    `layouts: [${layoutIds.join(", ")}], loading: ${loadingId} },`,
  );
}

For the app as it exists today, the generated module — the string handed to esbuild's stdin — is this:

the actual generated entry (paths shortened)import { hydrate } from ".../src/client/hydrate.ts";
import m0 from ".../src/app/blog/[slug]/page.tsx";
import m1 from ".../src/app/layout.tsx";        // imported ONCE, used by all four routes
import m2 from ".../src/app/blog/layout.tsx";
import m3 from ".../src/app/about/page.tsx";
import m4 from ".../src/app/slow/page.tsx";
import m5 from ".../src/app/slow/loading.tsx";  // loading.tsx ships too — see §4 for why
import m6 from ".../src/app/page.tsx";

hydrate({
  "/blog/[slug]": { component: m0, layouts: [m1, m2], loading: undefined },
  "/about":       { component: m3, layouts: [m1],     loading: undefined },
  "/slow":        { component: m4, layouts: [m1],     loading: m5 },
  "/":            { component: m6, layouts: [m1],     loading: undefined },
});

Note what is absent: error.tsx and not-found.tsx. Fallback documents don't hydrate (they carry no bundle and no payload — verified by grepping the 404/500 responses for client references: zero), so their components never ship. The kernel serves the finished bundle like any other framework-owned response:

src/core/handle.ts — the bundle is just another Responseif (options.clientBundle && url.pathname === CLIENT_BUNDLE_PATH) {
  return new Response(options.clientBundle, {
    status: 200,
    headers: { "content-type": "text/javascript; charset=utf-8" },
  });
}

Two honest observations, both recorded as future chapters rather than fixed quietly. Everything ships. The 1,091 KB dev bundle (unminified development React is most of it) contains every loader and even posts.ts — open the bundle and the blog "database" is legible inside it. Today that's demo data; in a real app this exact mechanism would ship connection strings. Which modules may reach the client? is the question React Server Components exists to answer, and softshell now owns the evidence for why it must be asked. And no code splitting — visiting /about downloads the blog page too.

§3 The payload — the serialization boundary

Requirement two: the same props. The components are now in the browser, but props live in server memory — data.post came out of a loader that ran on the server and will not run again in the browser. The only bridge between the two memories is bytes in the document. So: embed the props. The naive plan is one line — JSON.stringify(data) — and like §1, the honest move is to walk it into its walls:

what JSON.stringify silently does to loader dataJSON.stringify({
  post:  { slug: "hello-softshell" },   // ✓ plain data survives
  stats: loadPostStats(slug),           // ✗ a Promise -> {}   (silently!)
  fn:    () => {},                      // ✗ a function -> key dropped (silently!)
})
// -> '{"post":{"slug":"hello-softshell"},"stats":{}}'

That empty {} is a bomb. The blog page's stats panel calls use(data.stats) — Entry 022's deferred-streaming pattern — and use() requires a thenable. Hand it {} and hydration throws. The general lesson arrives before the fix:

Loader data is not automatically JSON. The moment you ask "how do I express server values JSON cannot?" you have started inventing a wire format.
In plain English The server and the browser are two people on the phone. The server can't hand the browser a live goldfish through the receiver — it can only describe things in words. Plain data survives description. A promise doesn't: it isn't a value, it's an IOU. Softshell's trick: wait until the IOU has been paid (the stream already waited for that anyway), then send a note reading "this one was an IOU — here's what it paid out." The browser reads the note and mints a fresh, already-paid IOU so the component code runs unchanged. Every fancy RSC "flight payload" is a grown-up version of that note.

Softshell's wire format is one tagged marker. The serializer walks the data; plain values pass through; a thenable becomes { $softshell: "promise", value }:

src/core/payload.ts — serializeForClient, the whole protocolexport async function serializeForClient(value: unknown): Promise<unknown> {
  if (value === null || value === undefined) return value;

  if (typeof value === "function") {
    console.warn("softshell: loader data contained a function; dropped");
    return undefined;                       // loudly, not silently like JSON
  }

  if (typeof value !== "object") return value;

  if (isThenable(value)) {
    const outcome = await settledWithin(value, 25);

    if (outcome.state === "pending")  return { $softshell: "pending-promise" };
    if (outcome.state === "rejected") return { $softshell: "rejected-promise" };

    return {
      $softshell: "promise",
      value: await serializeForClient(outcome.value),   // recurse: nested promises work
    };
  }

  if (Array.isArray(value)) {
    return Promise.all(value.map((entry) => serializeForClient(entry)));
  }

  const out: Record<string, unknown> = {};
  for (const [key, entry] of Object.entries(value)) {
    out[key] = await serializeForClient(entry);
  }
  return out;
}

But wait — serializing a promise's resolved value requires the promise to have resolved. When is that guaranteed? This is the chapter's most elegant timing fact. Recall Entry 022's envelope: the document is prefix → React's chunks → suffix. React's stream does not end until every Suspense boundary has streamed its content — meaning every promise a component consumed via use() has settled by the time the stream closes. So the payload is built exactly there, as the final chunk:

src/core/render.tsx — the trailer rides the stream's clockconst trailer = options.hydrate
  ? async () => {
      const payload: HydrationPayload = {
        routePath: route.path,
        params: ctx.params,
        data: await serializeForClient(data),          // promises are settled NOW
        layoutData: (await serializeForClient(layoutData)) as unknown[],
      };
      return payloadScriptTag(payload);
    }
  : undefined;

// ...inside the envelope, after React's chunks finish:
controller.enqueue(encoder.encode(ROOT_CLOSE));                    // </div> — #root closes
if (input.trailer) {
  controller.enqueue(encoder.encode(`\n    ${await input.trailer()}`));
}
controller.enqueue(encoder.encode(DOCUMENT_TAIL));                 // </body></html>

The settledWithin race covers the one case the stream's clock does not: a loader deferred a promise that no component ever consumed. Nothing awaited it, so it may still be pending — and the payload must never hold the response open waiting for it:

src/core/payload.ts — never let the payload block the streamconst pending = Symbol("pending");
const timeout = new Promise<typeof pending>((resolve) => {
  setTimeout(() => resolve(pending), timeoutMs);
});

try {
  const value = await Promise.race([promise, timeout]);
  return value === pending ? { state: "pending" } : { state: "fulfilled", value };
} catch {
  return { state: "rejected" };
}

What actually rides the wire on /blog/hello-softshell — captured from the real response:

view-source, last chunk of the stream</div>    <!-- #root closed; the container's children stay pristine -->
<script>window.__SOFTSHELL__ = {
  "routePath": "/blog/[slug]",       // registry key — the client never re-matches
  "params": { "slug": "hello-softshell" },
  "data": {
    "post": { "slug": "hello-softshell", "title": "Hello from Softshell data loading", ... },
    "stats": { "$softshell": "promise",
               "value": { "views": 1755, "rewinds": 3, "tapeWear": "well loved" } }
  },
  "layoutData": [null, { "postCount": 2, "archiveLabel": "cassette archive" }]
};</script>
</body>

Three placement decisions are visible in that capture, and each one prevents a specific bug:

  • Outside #root. hydrateRoot matches the container's children one-to-one against the tree it computes. A foreign <script> node inside the container is a guaranteed mismatch — so the envelope closes the root div first, then emits the payload.
  • Escaped. A post titled </script><script>steal() would otherwise terminate the payload script early and execute. payloadScriptTag rewrites every < to < — identical bytes to JSON's parser, inert to the HTML parser. Entry 007 escaped the title; the rule generalizes: data is now part of the document, so data is now an injection surface.
  • Ordered by platform semantics. The bundle's <script type="module"> sits in the <head> — but module scripts are deferred by default: fetched during streaming (a free head start), executed only after parsing completes, which is after the payload chunk ran. No DOMContentLoaded listener, no callback dance.
§4 The runtime — rebuilding the exact tree
In plain English hydrateRoot is a stage crew arriving at a set that's already built (the server's HTML), carrying the same blueprints (the same components with the same props). They don't rebuild the set — they walk through it, checking blueprint against reality, wiring up the lights and levers (event handlers, state) as they go. A "hydration mismatch" is the crew finding a wall where the blueprint promises a door: they tear that part down and rebuild it (client render). The show goes on, but the console complains about the wasted lumber.

Requirement three: the same shape. The bundle's generated entry calls hydrate(registry), and the runtime is a point-by-point transcription of server decisions — every line below answers "how did the server do it?":

src/client/hydrate.ts — the browser half, nearly in fullexport function hydrate(registry: ClientRegistry): void {
  const payload = window.__SOFTSHELL__;
  if (!payload) return;                       // no payload -> stay a static page

  const entry = registry[payload.routePath];  // server matched; client just looks up
  if (!entry) return;

  // Rebuild the render context. The URL comes from the browser itself;
  // params come from the payload. The Request cannot cross the wire, so the
  // client fabricates an equivalent one.
  const ctx: RenderContext = {
    request: new Request(window.location.href),
    url: new URL(window.location.href),
    params: payload.params,
  };

  const data = revive(payload.data);
  const layoutData = payload.layoutData.map((entryData) => revive(entryData));

  // Entry 018's wrap order + Entry 022's loading-Suspense + Entry 023's
  // per-layout data. Any difference in shape here is a hydration mismatch.
  let element: ReactElement = createElement(entry.component, { ctx, data });

  if (entry.loading) {
    element = createElement(Suspense, {
      fallback: createElement(entry.loading, { ctx }),
      children: element,
    });
  }

  for (let index = entry.layouts.length - 1; index >= 0; index -= 1) {
    element = createElement(entry.layouts[index], {
      ctx,
      data: layoutData[index],
      children: element,
    });
  }

  hydrateRoot(document.getElementById("root")!, element);
}

Read it against the server renderer and the mirroring is total — including loading.tsx: even on a page whose content long since streamed in, the client must recreate the <Suspense> wrapper, because the server rendered one and the shape must match. That is why loading components ship in the bundle. The reviver is the protocol's other half:

src/client/hydrate.ts — revive, marker by markerfunction revive(value: unknown): unknown {
  if (value === null || typeof value !== "object") return value;
  if (Array.isArray(value)) return value.map((entry) => revive(entry));

  const record = value as Record<string, unknown>;

  if (record.$softshell === "promise") {
    return Promise.resolve(revive(record.value));   // use() works again
  }
  if (record.$softshell === "rejected-promise") {
    const rejected = Promise.reject(new Error("softshell: rejected on the server"));
    rejected.catch(() => {});   // pre-handle: no unhandledrejection console spam,
    return rejected;            // but use() still observes the rejection
  }
  if (record.$softshell === "pending-promise") {
    return new Promise(() => {});                   // honest: it never settled there either
  }

  const out: Record<string, unknown> = {};
  for (const [key, entry] of Object.entries(record)) out[key] = revive(entry);
  return out;
}

What hydrateRoot then actually does, since this chapter should not treat it as magic: React renders the element tree in memory, then walks the container's existing DOM in lockstep with it — for each fiber, instead of createElement-ing a DOM node, it claims the node that's already there and records it as the fiber's stateNode. Text and attributes are trusted, not rewritten (that's why nothing flickers). Listeners were never per-node to begin with — React attaches one delegated listener set at the root and routes events to fibers — so "attaching behavior" is mostly bookkeeping. If the walk desynchronizes — the computed tree says <span>, the DOM has a <div> — that is a hydration mismatch: React 19 recovers by throwing the server DOM away and client-rendering, correct but wasteful, and the console says so. The framework now makes a promise it never made before — the second render will equal the first — and mismatches are the cost of breaking it.

The deferred stats were this chapter's real mismatch test. During hydration, StatsPanel calls use() on the revived promise — technically a different, already-resolved promise object. React suspends the boundary, the microtask delivers the value, the retry renders exactly what the server streamed, the DOM matches. One prop needed a judgment call: ctx.request is streams-and-state, not data — it cannot cross. The client fabricates new Request(location.href), which is honest in spirit because components only ever read ctx.url and ctx.params. The code that actually consumes requests — loaders — never runs in the browser at all. Sit with that asymmetry: loaders are server code; components are shared code. Two kinds of functions living in one file, separated only by convention — and "only by convention" is precisely the weakness the RSC chapter will exist to fix.

What does the ignition actually change? hydrate() runs exactly once per document load, returns nothing, and alters almost nothing you can see:

does NOT touch visible DOM nodes adopted, text trusted, pixels frozen builds the fiber tree each DOM node becomes some fiber's stateNode installs delegated listeners ONE set, on #root — not on your buttons initializes component state useState cells come alive inside fibers hands React ownership of #root future setState calls now mutate DOM

Hydration buys the capability to alter, not the alteration. And one loaded gun sits in the last line: hydrateRoot returns a Root object with a .render(newTree) method — and softshell currently drops it on the floor. Client-side navigation is, mechanically, "keep that Root and call .render() with the next route's tree instead of letting the browser reload." The handle the next chapter needs is already being returned to us.

§5 Proof of life
Proof of lifethis page hydrates the same way

The home page's counter streams from the server dead, then works after the bundle hydrates. This copy of it is plain HTML + a script — which is, of course, the entire point.

pressed 0 times · dead until the client bundle hydrates

Verified, case by case: the counter clicked alive in a real browser (state update, re-render). Zero hydration warnings on / (plain page), /blog/hello-softshell (inline Suspense + revived promise), and /slow — the maximal case: an entire page inside a Suspense boundary whose content arrived two seconds into the stream, hydrating against a revived two-second-old promise. The payload's wire format was inspected byte-for-byte ($softshell markers carrying the settled values), the payload confirmed outside #root, and the 404/500 fallback documents grepped clean of any client reference. The full Entry 020–023 status matrix and the streaming timings were re-run unchanged.

§6 What this still does not do
  • No code splitting — one bundle, every route, downloaded on first visit.
  • No server/client module split — loaders and the "database" ship to every visitor. The RSC motivation now lives in the repo as a grep-able fact, not a slogan.
  • No client-side navigation — links still tear down a living app to rebuild an identical shell. But notice the inventory: the browser holds every route's components, a tree-building runtime, and a wire format for route data. Soft navigation lacks exactly one ingredient — the next route's payload — and the protocol that serializes it already exists.
  • No rebuild-on-change — the bundle is startup-frozen like the manifest; both await the dev-module-graph / HMR chapter.
  • Wire-format gapsDate, Map, Set, cyclic references: none survive. Real flight formats handle these; softshell documents the line it drew.
  • The fetch adapter passes no bundle — serverless-shaped deployments serve pure HTML, unchanged since Entry 011 and honest about it.
§7 The waiting nobody wrote

Where is the code that waits to run the bundle until the deferred promises resolve? Nowhere. Three facts in three places, none aware of the other two:

promises settle React's contract (inside react-dom): a boundary -> React's stream closes can't flush until its promise settles -> our pump loop exits plain statement order in wrapAppStreamInDocument -> trailer chunk emitted the payload — settled, so serializable -> emitted -> parsing completes -> module executes the browser's contract, bought with type="module" -> hydrate() reads a payload that necessarily exists

Grep the repo for the coordination you'd expect — an event, a readiness flag, a Promise.all — and you find nothing. Each link is purely local; the design is the absence of that code. Two spec facts make it airtight, and neither is a network race: an inline classic <script> executes at its parse position (nothing to fetch), and a module script executes after parsing completes, always — the bundle finishes downloading at ~0.6s and then sits idle for ~1.4s while the stream is open, because "downloaded" and "eligible to run" are different states. The counterfactual proves the mechanism: had the payload itself been a module script, deferred modules run in document order — the head bundle first — and the whole design breaks. The payload being a boring inline classic script is load-bearing.

§8 Position is time — the experiment

Hypothesis (Interlude 001): moving the payload from trailer to <head> should destroy streaming, because in a sequential channel anything placed before the shell must be computed before the shell can leave. Implementation: a temporary ?payload-in-head=1 trap that emitted the payload in the head and gave the serializer a 10s promise budget — genuinely waiting was the experiment. The trap was scaffolding, removed right after the measurement; the number was the point, not the feature. Measured:

route placement first byte total /slow trailer 0.003s 2.010s /slow head 2.003s 2.004s <- TTFB = total /blog/hello-softshell trailer 0.004s 1.204s /blog/hello-softshell head 1.204s 1.205s

First byte collapsed to exactly total time — renderToString reinvented by one placement decision. Two findings we didn't predict: the head payload carries real resolved values and hydration stays clean, so this is a slow but correct configuration — the more damning result, since nothing errors and Entry 022's entire value silently evaporates. And React still "streams" into the firehose: the skeleton and $RC swap are still in the wire, born and replaced in the same network burst — a movie fully downloaded, then played at 1000×. (The trap has since been removed — replay the experiment in the simulator.)

The general principle, now measured instead of asserted: order in a stream is a dependency schedulehead-of-line blocking, the same phenomenon that killed HTTP/1.1 pipelining, reproduced with a template string. And it only equals time because the browser is an incremental parser: a consumer that waited for complete files would render at 2.0s in either mode. The whole streaming chapter compresses to one rule:

Sort your output by cost-of-knowing. Cheapest first — the status code — then the shell, then data as it settles, and the payload dead last.
In plain English One checkout line, no overtaking. The person at the front needs a price check that takes two minutes — so everyone behind them waits two minutes, even the ones holding a single apple. That's head-of-line blocking. Putting the payload in the head puts the price-check customer at the FRONT of the line; putting it in the trailer sends them to the back, and every apple-holder (the shell, the skeletons) checks out instantly. Same customers, same total work — only the order changed, and in a line, order is everything.
The milestone: Softshell pages are no longer printed — they are alive. The same components render on the server for the first paint and in the browser for behavior, with a generated bundle carrying the code and a serialized payload carrying the props across the wire.

025 · Client-side navigation

Reconcile, don't reload
§0 The obliteration

The motivation. Click "Blog example" before this entry and watch the browser destroy a perfectly good application: a hydrated app — fibers, state, listeners — obliterated; a new document streamed and parsed; the same 1.1 MB bundle re-executed; hydration re-run from zero; and an identical root layout rebuilt from nothing. The framework has known both pages share that shell since Entry 018; the navigation model just couldn't use the knowledge. Interlude 001 took the inventory — all routes' components in the bundle, a tree-building runtime, a wire format for props, a discarded Root — and this chapter spends it.

The server still owns the data. The browser now owns the transition.
§1 The design decisions

1 — the marker is a header, not a query param. The same URL must answer as pixels or as props. A query param writes the request into the address — but the address is data the app reads (?break-layout proves loaders consume ctx.url.searchParams), so the framework would forever strip its own marker before building ctx. A header is the envelope's sticky note — a channel app code never reads:

src/core/payload.tsexport const PAYLOAD_REQUEST_HEADER = "softshell-payload";

2 — signals ride inside the envelope. HTTP redirects don't survive fetch() — the browser follows a 307 transparently and hands the runtime the target's HTML instead of saying "go elsewhere." So Entry 020's signals gain a JSON-level serialization, and title joins the payload (no new <head> will set the tab title for us):

src/core/payload.ts — the four answers a data request can getexport type NavigationEnvelope =
  | ({ kind: "render" } & HydrationPayload)
  | { kind: "redirect"; location: string }
  | { kind: "notFound" }
  | { kind: "error" };

3 — deferred promises are awaited fully, and the regression is priced. No stream ends to guarantee settlement, so the serializer's budget goes 25ms → 10s and deferred data is genuinely awaited. Measured cost: soft-navigating to /slow blocks 2.003s — worse than a hard load's 4ms shell. Mitigation: a progress bar over a still-interactive old page. Fix: the next two chapters, named now — the matcher goes isomorphic, payloads learn to stream. This entry's worst behavior is the next entries' reason to exist.

4 — plain <a> tags, upgraded by delegation. One document-level listener; the browser's default is selectively substituted, never removed:

intercept when: plain left click · no cmd/ctrl/shift/alt · same origin · no target · no download · not a same-page #hash otherwise: the browser does exactly what it always did

5 — keep the Root, reconcile the tree. hydrateRoot's return value — discarded since Entry 024 — is stored, and navigation becomes one call: root.render(nextTree). React diffs against the mounted tree; the root layout is the same component in the same position, so it is not remounted — its DOM and state survive the crossing. 6 — two guards: a navigation ticket counter (rapid clicks race; a stale response must never win) and the escape hatch — anything unrecognized → window.location.assign(). The browser is always the correct fallback.

§2 The server half

The point is what doesn't change: same matching, same parallel loaders. The data branch sits after the shared loader phase and only changes the output format:

src/core/handle.ts — outcomes become an envelope instead of a documentif (isPayloadRequest) {
  return payloadResponse(route, ctx, layoutOutcomes, pageOutcome);
}

// inside payloadResponse:
const failed = layoutOutcomes.find((o) => !o.ok) ?? (!pageOutcome.ok ? pageOutcome : undefined);
if (failed && !failed.ok) {
  return envelopeResponse(envelopeForFailure(failed.error));   // signals -> kinds
}

return envelopeResponse({
  kind: "render",
  routePath: route.path,
  params: ctx.params,
  data: await serializeForClient(pageOutcome.data, DATA_REQUEST_PROMISE_BUDGET_MS),
  layoutData: (await serializeForClient(layoutOutcomes.map(outcomeData), ...)) as unknown[],
  title: title ?? "Softshell",
});
§3 The client half
src/client/hydrate.ts — navigate(), the whole transitionconst ticket = ++navigationSequence;
showProgress();

const response = await fetch(href, { headers: { [PAYLOAD_REQUEST_HEADER]: "1" } });
const envelope = (await response.json()) as NavigationEnvelope;

if (ticket !== navigationSequence) return;      // a newer navigation superseded us

if (envelope.kind === "redirect") {
  return navigate(envelope.location, options);  // JSON-level redirect (max 5 hops)
}
if (envelope.kind !== "render") {
  window.location.assign(href);                 // escape hatch: browser takes over
  return;
}

root.render(assembleTree(envelope, url));       // RECONCILE — the whole chapter
history.pushState({}, "", url.pathname + url.search);
document.title = envelope.title ?? "Softshell";

First hydration and every navigation share one assembleTree() — the payload-to-tree assembly from Entry 024, factored. Back/forward is the popstate event: pushState-created entries don't reload; the browser says "you handled forward, you handle back," and navigate() re-runs without pushing.

In plain English Redecorating versus demolishing. A hard navigation demolishes the house and rebuilds it from the blueprints — even the walls that were identical in both versions. root.render() hands React the new blueprint and React walks the house comparing room by room: the entrance hall (root layout) is the same in both plans, so it isn't touched — the coats stay on the hooks (state survives). Only the room that actually differs (the page) gets its furniture swapped. Same blueprints, radically less demolition.
§4 Verified — the same DOM node crossed five pages

The proof is a session-clicks counter living in the root layout's header. Clicked to 3, then: home → blog → back → redirect link → back. After five soft transitions the count read 3 — and stronger, the check returned sameDomNode: true: the literal button element survived every crossing. URL and title updated per hop; /blog/latest soft-followed its JSON redirect and pushed the final location; the not-found link triggered a real hard fallback (fonts re-fetched, counter wiped to 0, server-rendered 404 boundary). The network log tells it in one glance:

six soft navigations: six bare fetches — zero fonts, zero bundle, zero documents one 404 escape hatch: full document load — fonts and bundle pulled again wire matrix: render / redirect / notFound / error all verified by curl /slow data request: blocks 2.003s (Decision 3, priced) · HTML path unregressed
§5 What this still does not do
  • No payload caching — every soft nav re-fetches, even back/forward. The cache chapter finally has a concrete thing to cache.
  • No instant skeletons — Decision 3's regression stands until the matcher goes isomorphic (026) and payloads stream (027).
  • No prefetching, no scroll restoration on back — and navigating to the current page re-renders instead of short-circuiting.
  • Dev-server-only — the fetch adapter ships no client runtime, so it never navigates softly.
The milestone: Navigation stopped being "destroy the world, rebuild the world." The browser fetches the next route's props, React reconciles the next tree against the mounted one, and shared shells survive the crossing.

026 · The matcher goes isomorphic

Same functions · both runtimes

The motivation. The registry is keyed by patternsregistry["/blog/hello-softshell"] is undefined; only "/blog/[slug]" is a key. The bridge from concrete URL to pattern is matching, and it lived only on the server: the browser held a phone book it couldn't read. The workaround was already shipping — the envelope's routePath is the server doing the lookup and mailing the answer back, one round trip per navigation, partly just to learn the destination's own identity.

Matching was never server code. It was pure functions over plain data that happened to live on the server.

The insight that made it tiny: nothing new crosses the wire. The registry's keys ARE the route patterns, and routePathToSegments is the pure parser — so the client derives its own manifest from what it already holds. Zero generator changes:

src/client/hydrate.ts — the manifest, derived at hydrate() timeclientManifest = sortRoutesBySpecificity(
  Object.keys(clientRegistry).map((path) => ({
    path,
    segments: routePathToSegments(path),
  })),
);
// the sort is NOT optional — specificity order is the matching algorithm;
// skip it and /blog/new vs /blog/[slug] could disagree with the server

The refactor: types that tell the truth. matchRoute demanded full RouteModule[] and read exactly one field. The generic bound states the real requirement — and the body did not change by one character:

src/core/routes.ts — the matcher's honest contractexport type MatchableRoute = {
  path: string;
  segments: RouteSegment[];
};

export function matchRoute<T extends MatchableRoute>(
  routes: T[],
  pathname: string,
): RouteMatch<T> | undefined { /* body: unchanged */ }

Server-side, inference picks T = RouteModule and handle.ts compiles untouched — proof the refactor changed what the types claim, not what the code does. Client-side, T = MatchableRoute. Same function, two inferred shapes; RouteModule satisfies the bound structurally, no declaration anywhere. One trap for the record: importing the type without the type modifier typechecks fine and then kills the server at startup — esbuild goes looking for a runtime export that type-erasure deleted. The compiler and the bundler disagree about what an import means; import { type MatchableRoute } keeps them aligned. (Found in review — the one real bug in this entry's diff.)

What navigate() does with it — gate before chrome, warn after truth:

src/client/hydrate.ts — three usesconst localMatch = clientManifest
  ? matchRoute(clientManifest, targetUrl.pathname)
  : undefined;

if (!localMatch) {
  window.location.assign(href);   // unknown route: zero fetch, straight out
  return;                          // (sits BEFORE the ticket and progress bar)
}

// …fetch, envelope arrives…

if (envelope.routePath !== localMatch.route.path) {
  console.warn("softshell: client manifest disagrees with server — trusting the server");
}
// warn-and-continue: the tree still assembles from envelope.routePath.
// The server stays the authority; the local answer is advisory.

Drift — what two copies of the truth costs. A matched-but-missing resource is not drift: both matchers agree /blog/missing-post fits [slug]; the loader's notFound() is a data decision (Entry 020's structure-vs-existence distinction, resurfacing). Real drift is identical functions fed different data — and the mechanism is time:

Monday: a tab opens -> its bundle carries Monday's manifest Tuesday: src/app/pricing/page.tsx ships; the server restarts the Monday tab still holds Monday's manifest -> local: "no such route" server: "/pricing"

The moment the manifest crossed the wire it became two copies with independent lifetimes. The warn detects; the 025 escape hatches already recover (unknown either way → hard navigation → the reload IS the manifest update). That's Next.js's buildId strategy in embryo.

In plain English The browser has a phone book (the registry) but never learned to look things up in it — so it's been calling the operator (the server) for every single address, even ones that obviously don't exist. This entry teaches the browser to read its own phone book: same lookup rules the operator uses, copied into the browser. Now hopeless addresses get rejected instantly, no call made. The catch: the browser's phone book is a printed copy. If the operator gets tomorrow's edition while your tab holds yesterday's, they can disagree. The rule when that happens: trust the operator — and if things get confusing, hang up and walk into the office (a full page reload, which hands you the new edition).

Honest billing. A normal navigation feels byte-for-byte identical to 025 — the matcher produces identity, not data; loaders are server code; the fetch wait is untouched. Even 027's skeletons will key off frame 1 of the streamed payload, not local matching. Today's yield: the unknown-route skip and the drift check. The structural yield: this is the Pages-Router architecture (which Remix, React Router, SvelteKit, Nuxt, and TanStack Router all ship today — the App Router is the outlier, not the trend), and the chapter that comes to collect is code splitting: when registry values become "chunk, go fetch it," URL → route → which chunk must be answered before any network, because that answer is the fetch being started.

Verified: typecheck with handle.ts untouched; server startup doubling as the bundle test (matcher confirmed inside the served bundle); the 025 matrix unchanged (counter survived nav + Back at 3, redirect chain pushed the final URL, titles updated); /nope produced exactly one network entry — a document navigation, no preceding payload fetch; zero drift warnings in normal operation. Code by the log's author; review found the esbuild type-import trap.

Appendix · the definition shelf

Load-bearing words · one card each

Words the log leans on as if everyone already knows them. Each card is one unit of understanding: a tight definition, then something concrete — a diagram, the raw bytes, or a knob to turn. Dotted-underlined words like this throughout the log jump down here.

ClosureJAVASCRIPT

A function that permanently carries the variables from the place it was born — not as copies, but as live links — even after that place has finished running. createHandler(routes) returns handle; handle can use routes on every request, forever, without being handed it again. The closure is why.

same function code, two separate births — so two private count variables that never leak into each other
Promise / ThenableJAVASCRIPT

An IOU for a value that isn't ready yet. It has exactly three states — pending, fulfilled (with a value), rejected (with an error) — and one door: .then(callback), meaning "call me when settled." await and React's use() are politer spellings of that door. A "thenable" is anything with a .then — duck-typing for promises. Crucially, a promise is not data: that is why JSON.stringify flattens it to {} and why Entry 024 had to invent a payload protocol.

state: —
Module & Module GraphJAVASCRIPT

A module is one file that declares what it needs (import) and what it offers (export). The module graph is the who-needs-whom map: start at one entry, follow every import, and you reach everything the program is made of. Bundlers walk the graph once at build time; a browser loading modules natively walks it at runtime — one network round trip per edge, which is Wall 4's waterfall.

generated entry ──▶ app/page.tsx ──▶ react ──▶ scheduler │ └──▶ core/types.ts (types only: erased) └──▶ app/blog/[slug]/page.tsx ──▶ blog/posts.ts ◀— why the "database" ships └──▶ core/navigation.ts
Bare SpecifierJAVASCRIPT

The text after from in an import. Three kinds — and browsers can only resolve two of them. The third names a package, and turning a package name into an actual file is a Node convention (probe node_modules, read package.json, interpret its "exports" map) that no browser implements. Something before the browser must rewrite it — that job is a bundler's Wall 2.

import x from "./posts.js" relative URL browser: fine import x from "/src/a.js" absolute URL browser: fine import x from "react" BARE browser: TypeError — who is "react"?
SerializationJAVASCRIPT

Turning a live, in-memory value into plain text that can cross a wire or be stored — plus the reverse trip (revival). JSON is the lingua franca, and it is lossy: it can only describe what a value is, never what it does or will be. Everything Entry 024 §3 does exists to paper over exactly one row of this table.

SURVIVES JSON DIES IN JSON string, number, boolean function -> silently dropped null, array, object Promise -> {} (the bomb) Date -> "2026-07-09…" (a string now) Map / Set -> {} Request -> {} (streams aren't text) cyclic refs -> TypeError
JSXJAVASCRIPT

Not HTML-in-JavaScript — a syntax shorthand that compiles to ordinary function calls. The browser never sees the angle brackets; a compile step (esbuild's jsx: "automatic") rewrites them first. That's Wall 3: source files in this repo are not executable as written.

you write: <button onClick={fn}>Press me</button> what runs: import { jsx } from "react/jsx-runtime"; jsx("button", { onClick: fn, children: "Press me" })
Stream & ChunkHTTP

A buffer is the whole value, delivered once. A stream is the same bytes as a sequence of chunks over time, with an explicit "done" signal. An HTTP response body was always allowed to be either — Entry 022 just switched softshell's renderer from the first shape to the second. Press the button and watch the only difference that matters: when bytes arrive, not which bytes arrive.

buffered: streamed:
The Raw HTTP ExchangeHTTP

Underneath every framework abstraction, an HTTP exchange is two small text documents. Note what comes first in the response: the status line. It is literally the first bytes on the wire — which is Entry 022's status-code deadline, stated as plumbing rather than principle.

REQUEST (browser -> server) RESPONSE (server -> browser) GET /blog/hello-softshell HTTP/1.1 HTTP/1.1 200 OK ◀— the verdict, first host: 127.0.0.1:3000 content-type: text/html; charset=utf-8 accept: text/html transfer-encoding: chunked ◀— "body arrives in pieces" <!doctype html><html>… ◀— then the document
Incremental ParsingHTTP

The browser never requires, receives, or checks "a complete HTML file." The parser is an assembly line — bytes → tags → DOM nodes appended live, painted every frame — and it was born that way (1991 dial-up DNA). Open elements are a normal parser state, not an error; </body> and </html> are literally optional in the spec; end-of-input comes from the transport (chunked encoding's terminator), not from a tag. The HTML text is instructions, consumed and discarded — the DOM is the real artifact. View-source shows the recipe; the page is the cake, frosted while the recipe is still printing.

keeps working mid-stream: painting, layout, CSS, inline scripts, $RC swaps gates on parse completion: module/deferred scripts, DOMContentLoaded, readyState "complete", the tab spinner
Head-of-Line BlockingHTTP

In any strictly sequential channel — one queue, no overtaking — a slow item at the front delays everything behind it, including things that were ready long ago. Order becomes a dependency schedule: placing X before Y means "everything X needs must finish before Y can even begin to travel." It killed HTTP/1.1 pipelining, it's why one lost TCP packet stalls a whole connection, and softshell reproduced it on purpose with ?payload-in-head=1: the expensive-to-know payload at the front, the cheap-to-know shell trapped behind it, TTFB 0.003s → 2.003s.

trailer: [shell 4ms] [skeletons] [data as it settles] [payload] ttfb 0.003s head: [payload — waits 2s for everything] [shell] [the rest] ttfb 2.003s same bytes, same total work — only the ORDER changed
Status CodeHTTP

The three-digit verdict opening every response. The class is the meaning: 2xx "here it is", 3xx "go elsewhere", 4xx "your side's problem", 5xx "my side's problem". Softshell produces exactly four, each from a different exit of the pipeline:

200 rendered (buffered or streamed) the happy path 307 redirect() signal + a Location header, no body 404 no route matched OR notFound() two different failures, one code 500 an unexpected throw boundary page, or plain text if none
FiberREACT

React's private bookkeeping record, one per component or element in the tree. Your function returns elements — cheap descriptions, thrown away each render. The fiber is what persists between renders: which component this spot holds, its current props and state, links to parent/child/sibling fibers, and — for DOM elements — which real DOM node it owns (stateNode). Rendering reconciles elements against fibers; fibers then touch the DOM. Hydration in one sentence: fill every fiber's stateNode with a node that already exists instead of creating one.

<StatsPanel stats={…}/> element: a description, discarded every render │ reconciles against fiber { type: StatsPanel, memoizedProps, memoizedState, child ─┐ } persists ▼ ▼ fiber { type: "div", stateNode: ──────▶ <div> in the real DOM }
ReconciliationREACT

What root.render(newTree) actually does: not a rebuild, a diff. React walks the new element tree against the mounted fiber tree, position by position. Same component type in the same position → the fiber is kept — its DOM node, its state, its listeners all survive — and only its props update. Different type → the old subtree unmounts, the new one mounts. This is why soft navigation preserves the root layout (same type, same position, every route) while swapping the page leaf, and it's the entire payoff of Entry 018 storing routes as trees.

mounted: RootLayout( HomePage ) next: RootLayout( BlogPostPage ) └─ same type, same position ─┘ └─ differs ─┘ result: RootLayout kept (DOM + state intact) · HomePage unmounts · BlogPostPage mounts
Suspense Boundary & ShellREACT

A fenced region of the tree with a designated stand-in. If anything inside the fence says "not ready" (use() on a pending promise), React shows the fallback for just that region and keeps rendering everything outside it. The shell is precisely "everything outside every fence" — the part that must finish before the first byte, because it decides the status code.

RootLayout ──────────────────┐ BlogLayout │ the SHELL: streams at once, decides the 200 post title, body …───────┘ ┌─ <Suspense fallback={skeleton}> ─┐ │ StatsPanel — use(stats) ⏳ │ the fenced region: fallback now, └───────────────────────────────────┘ real content as a later chunk + $RC swap
Hydration MismatchREACT

Hydration's contract: the browser's render must equal the server's markup, node for node, text for text. A mismatch is any disagreement. React 19 recovers by discarding the server DOM for that region and client-rendering it — correct, but the server's work is wasted and the console tells you so. The classic self-inflicted one:

function Footer() { return <span>rendered at {new Date().toLocaleTimeString()}</span>; } server markup: rendered at 14:03:07 (rendered on the server's clock) browser render: rendered at 14:03:09 (re-run two seconds later) ✗ MISMATCH
Event DelegationREACT

React does not put an onclick on your button. It installs one set of listeners at the root container and relies on the DOM's own bubbling: any click anywhere rises to the root, where React looks up which fiber it landed on and runs that component's handler. This is why hydration's "attaching behavior" is mostly bookkeeping — the per-node wiring you'd expect simply doesn't exist to attach.

click on <button> ▲ bubbles: button → div → … → #root (built into every browser) #root's single React listener fires → finds the fiber for that button → runs its onClick
Route ManifestSOFTSHELL

Everything discovery learned about the filesystem, frozen into plain data the request path can use without ever touching the disk. It is the boundary object between the two worlds (filesystem world / request world) — and since Entry 024, the same manifest also generates the client bundle's entry. Softshell's manifest today, abridged:

path segments layouts boundaries load / [ ] [root] — — /about [about] [root] — — /slow [slow] [root] loading.tsx ✓ (defers) /blog/[slug] [blog, :slug] [root, blog] error + 404 ✓ (defers stats)
Layout ChainSOFTSHELL

The ordered list of layout.tsx files from the app root down to a page's directory — stored parent-first on the route, reversed at render time so the root ends up outermost. Entry 023 truncates it on loader failure (slice(0, failedIndex)); Entry 024 replays it in the browser. One list, three chapters leaning on it.

/blog/hello-softshell chain: [ src/app/layout.tsx , src/app/blog/layout.tsx ] (parent-first data) tree: RootLayout( BlogLayout( BlogPostPage ) ) (leaf-first wrap)
Render Context (ctx)SOFTSHELL

The framework's normalized summary of one request: { request, url, params }. Built once per request by the handler, handed to every loader and every component. It exists so app code never parses raw requests — the framework digests the wire format, the app reads clean fields. (It is also the one prop that cannot cross to the browser whole: the client fabricates an equivalent Request from location.href.)

Route SpecificitySOFTSHELL

The tie-breaking rule when several patterns fit one URL, applied once at discovery by sorting the manifest: more segments first, then more static segments first, then alphabetical for stability. The effect: an exact route always beats the pattern that could swallow it.

GET /blog/new candidates, in sorted order: 1. /blog/new (2 segments, 2 static) ◀— wins, checked first 2. /blog/[slug] (2 segments, 1 static) 3. / (1 segment)