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.
The Softshell Log.
ENT
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.
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?
The kernel is the smallest useful framework idea, and everything later attaches to it:
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.
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>
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.
fetch.ts turned out nearly empty: that particular country happens to use the same plug the laptop was built with.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.)
tsxruns 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/ReadableStreamtypes - dependencies:
react,react-dom— that's all Day 1 needs
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.
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);
}
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.
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:
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("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
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.
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.
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.
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);
});
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 };
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
As the code grows more complicated, the model must stay simple:
Everything else — all eleven chapters that follow — is an elaboration of those four lines.
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.
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.
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.
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:
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> { ... };
}
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:
/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.
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?
/about and /blog/x share the outer doll (same header) but only blog pages sit inside the blog doll.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.
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:
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>) { ... }
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.
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:
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:
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.
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:
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:
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,
});
}
}
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:
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.
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.
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:
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.
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:
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.
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.
- 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.
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: