The boundaryserver & client components
What if your components could talk to the database directly?
They can, and most of yours already do. Every component in the App Router starts on the server, so it can call the API, read the database, hold the key, and finish its work before the page reaches your visitor. The browser gets the result, not the work. "use client" marks the one file that needs the browser, and that file is usually small.
The component below fetched this repo’s latest commit in Node.js and arrived as finished HTML. Add a copy button to the hash and watch where it lands: in its own file, inside its own line. The fetch never moves. The button is the only thing that ships.
// no directive
latest commit de11275
Merge pull request #437 from hasToggle/Kheirah/capability-first-framing13 Sept 2026, 16:49:11 UTC
Fetched from api.github.com in node v24.19.0 at 02:12:38 UTC, then cached — re-served to every visitor until the entry revalidates or a deploy replaces it.
- runs in Node.js: the database, the secrets, the filesystem
- ships 0 kB of JavaScript
- renders once, returns HTML, and is gone
the dashed line is the server · every file lives here unless its first line says otherwise
card.tsx · before, after
// card.tsx — as it started. No directive: a Server Component,
// like every file that doesn't say otherwise.
import { cacheLife } from "next/cache";
async function getLatestCommit() {
"use cache";
cacheLife("hours");
const response = await fetch(
"https://api.github.com/repos/hasToggle/hasToggle.dev/commits/main"
);
const { sha, commit } = await response.json();
return { sha: sha.slice(0, 7), subject: commit.message.split("\n")[0] };
}
export async function Card() {
const { sha, subject } = await getLatestCommit();
return <p>latest commit {sha} — {subject}</p>;
}
// card.tsx — with the copy button. Still no directive: the fetch never
// left Node, and the one file that needs the browser carries its own line.
import { CopyButton } from "./copy-button";
export async function Card() {
const { sha, subject } = await getLatestCommit();
return <p>latest commit {sha} <CopyButton value={sha} /></p>;
}
// copy-button.tsx — the entire client bundle of this card.
"use client";
import { useState } from "react";
export function CopyButton({ value }) {
const [copied, setCopied] = useState(false);
return (
<button onClick={() => {
navigator.clipboard.writeText(value);
setCopied(true);
}}>
{copied ? "copied" : "copy"}
</button>
);
}The cachecaching & revalidation
Your page can be static and up-to-date at the same time.
That used to be a contradiction. use cache renders a component once and keeps the output as a cache entry that every visitor gets. That is the static half. A cache tag is the handle on that entry: pull it and the entry expires, for everyone, at once. That is the up-to-date half. Nothing renders a replacement until someone asks for the page, and everyone after them gets the fresh entry free.
The stamp below is this page’s own entry, wearing a six-character fingerprint so you can tell one render from the next. Revalidate it and a fresh one reaches every reader of this page in about the time it takes the label to change back. It feels like one event.
It is three, and slow motion shows you each of them. Expire the entry, then ask for the page, and watch the color: it changes twice, not once. The gap between the two is what your cache logs have been naming all along. The request that refills the entry logs REVALIDATED, reason tag-based deletion, because that request did the rendering. STALE is the same gap handled softly: the old entry served while a fresh one renders.
entry #25ec44
- rendered
- 14 Sept 2026, 02:12:36 UTC
- served
- from the static shell — the same entry every visitor gets
expires this page’s cache entry and renders a fresh one — for every visitor, immediately.
bake.ts + actions.ts + the ask
// bake.ts — the cache entry (lives in the static shell)
export async function getBake() {
"use cache";
cacheTag("landing-shell");
cacheLife("days");
return {
bakedAt: new Date().toISOString(),
id: crypto.randomUUID().slice(0, 6), // the fingerprint — and a CSS color
};
}
// actions.ts — the mutation
"use server";
export async function rebakeShell() {
updateTag("landing-shell"); // expires it now, for everyone
// The tag's expiry is stamped after this render finishes, so the bake in
// this response was cached for nobody. The next request makes the real one.
return { rebakedAt: new Date().toISOString() };
}
// rebake-panel.tsx — button two, the "ask"
router.refresh(); // no cache API anywhere — one more request for the page,
// indistinguishable from a new tab or another visitorThe streamstreaming & suspense
What if the page didn’t wait for its slowest part?
It doesn’t have to. The static shell ships first, and each slow part leaves a fallback in its place. When a part finishes on the server, its HTML streams down the same response and takes the fallback’s place. The fast parts never wait for the slow ones, whatever they sit next to.
The three rows below are slow on purpose, with their delays printed on them. Each one is a Server Component that finishes on the server and streams in when it is done. Await everything and nothing appears until the slowest is back. Add a fallback and a placeholder takes the blank’s place, but the rows still arrive together. Wrap each part and the order holds: the shell, then the rows, fastest first. The response view shows the same run as the server sent it: one response, held open, a chunk per boundary.
a boundary per row · three arrivals · each row waits only for itself
stage.tsx · the three arrangements
// the same three calls in every arrangement. only the boundary moves.
const rows = [
{ label: "a quick database query", delayMs: 400 },
{ label: "a third-party API with opinions", delayMs: 1100 },
{ label: "the legacy service nobody dares", delayMs: 1900 },
];
// 1 — fetch it all first, then render
<Suspense fallback={null}>
<GroupRows /> {/* awaits all three, then returns all three */}
</Suspense>
// 2 — add a fallback. this is what loading.tsx is.
<Suspense fallback={<GroupPending />}>
<GroupRows /> {/* same component, same wait */}
</Suspense>
// 3 — wrap each part
{rows.map((row) => (
<Suspense fallback={<PendingRow {...row} />} key={row.label}>
<SlowRow {...row} /> {/* awaits its own work, and nobody else's */}
</Suspense>
))}The mutationserver actions & cookies
What if the form just called the function?
It does now. A Server Action runs on the server and plugs straight into a form’s action: no endpoint to design, no fetch to write, no JSON contract to keep in sync. Add one to the count and follow it: the form calls the function, the function writes the new count, and Next.js re-renders the page around it. That is the whole thing.
The count lives in a cookie your browser carries but your JavaScript cannot open, which is what httpOnly means, and a Server Component reads it back. The value is safe from the tab that displays it, without a line written to arrange that.
reading your cookie on the server…
the form posts to the function · the function writes the cookie · the page re-renders around it, with or without JavaScript
actions.ts + mutation-panel.tsx
// actions.ts — the entire backend of this demo
"use server";
export async function pressTheButton() {
const jar = await cookies();
const count = parseCount(jar.get("playground-presses")?.value) + 1;
jar.set("playground-presses", String(count), { httpOnly: true });
// Cookie changed, so Next.js re-renders this page's server tree —
// the count you see is read back on the server, not tracked in JS.
// The request view is drawn from what this function saw:
const incoming = await headers();
return {
actionId: incoming.get("next-action"), // the function's id — the route
contentType: incoming.get("content-type"), // multipart/form-data
count,
};
}
// mutation-panel.tsx — the entire frontend
const [receipt, formAction, pending] = useActionState(pressTheButton, null);
return <form action={formAction}>{/* a button */}</form>;The stateuseState & re-renders
What actually happens when you press +1?
A component is a function. Render means React calls it and paints what it returns, so a value that has to survive from one call to the next needs somewhere to live in between. useState is that place. The setter does two jobs: it stores the new value where React keeps it, and it schedules the next call, the render that paints it.
Press +1 and the number moves, the way counters always have. Flip slow motion and press again to see the render that moved it: React runs StateCard() again, top to bottom, useState hands back the value it kept, and the line that paints the count paints the new one. Then the number has moved.
0
- setCount stores the value where React keeps it
- then React calls the component again
a state variable is a value React keeps between calls · setting it is permission to call the component again
state-card.tsx
// state-card.tsx
"use client";
import { useState } from "react";
export function StateCard() {
const [count, setCount] = useState(0);
// setCount does both jobs: it stores the value where React keeps it
// between calls, and it schedules the render that repaints the screen.
return (
<Card pill="useState">
<button onClick={() => setCount(count + 1)}>+1</button>
<p>{count}</p>
</Card>
);
}The syllabus grows
Still to build.
The plan is everything Next.js can do, and as much of Vercel as can be proved from inside a web page. One chapter at a time, in public.
- navigation & prefetching
- dynamic routes & params
- next/image, fonts & the asset pipeline
- metadata, sitemaps & SEO
- optimistic UI & useActionState
- proxy, redirects & rewrites
- error, not-found & recovery
- parallel & intercepted routes
- i18n & locale routing
- view transitions
- ISR & pages baked on demand
- edge network & geolocation
- feature flags & Edge Config
- web vitals, measured live
- preview deploys & instant rollback
- cron, queues & background work
- blob, key-value & Postgres
The cohort
Some things move faster with a coach.
I spent years as a lead web coach in bootcamps, watching the same walls catch everyone — hydration, caching, the boundary, all the chapters above. The playground shows you the wall. The cohort gets you over it: small paid groups, building production apps on exactly these topics, with the same AI workflow that built this page.
Paid, small, and honest about both.
The waitlist
Want a seat when the cohort opens?
Put your address here and you hear first: when seats open, what the cohort will build, and which chapters in the lab it draws on.
Seats open to this list before anywhere else.
Three kinds of email: seats opened, a chapter shipped, the cohort changed shape. Leaving is one click, and it works the first time.
Frequently asked questions
Before you poke anything.
What exactly am I looking at?
A place to find out what happens when you press things. Each chapter pairs a demo with its source — press the button, watch the cache expire, read the code that did it. The plan is to cover everything Next.js and Vercel can do, one chapter at a time. The official docs are good; this is the lab bench that belongs next to them.
Is this official?
No. Vercel hasn’t endorsed this site, and nobody on the Next.js team sees a chapter before it ships. The mistakes are ours, and so is the freedom to say which parts are confusing.
Is this free?
The playground is free. Completely, permanently, no-asterisk free. The paid thing is coaching: small cohorts where you build production apps with me on exactly these topics, AI workflow included. The page teaches; the cohort makes it stick.
The playground doesn’t get better if you buy the cohort. It’s the same page either way.
Who is this for?
Anyone from “I want to build things but don’t code yet” to “I have opinions about caching strategies”. If you’ve ever refreshed a page wondering why your update didn’t show up, or sprinkled “use client” everywhere just to be safe, you’re the audience. Beginners get footing. Seniors get a reference they can poke.
Why not just read the docs?
Do read the docs — we link them from every chapter, on purpose. But reading about streaming and watching three skeletons resolve in delay order are different kinds of knowing. Docs tell you how it works. A playground lets you find out what happens.
How is this site built?
In public, with AI. The repo is on GitHub. The building happens in Conductor, with Claude Code doing the typing, and Entire.io publishes the process — prompts, checkpoints, wrong turns included — to a second public repo. This site is its own biggest demo.
The AI writes the code. Someone still has to decide what ships, and that part hasn’t been automated.
What lands in my inbox on Monday?
One new chapter and the write-up that goes with it. Five minutes, no filler, and nothing you have to read on a schedule.