The boundaryserver & client components
I’ll put “use client” on it, to be safe.
Safer than what? Every component in the App Router already runs on the server. "use client" is not a precaution, it’s a purchase — for that file and everything it imports. You buy useState, useEffect and onClick. You pay with the database call you can no longer make from here, the API key you can no longer read, and however much React your visitor downloads on their phone.
The card below is a Server Component — no directive, because that is the default. It fetched this repo’s latest commit in Node.js and arrived as finished HTML; the component that made it is already gone. Use the deck to give the hash a copy button and the compiler refuses: the same error that sends everyone here, and it names its own fix. Apply that fix and the compiler refuses again — the directive claimed the whole file, and "use cache" has no client form. The second refusal names the real fix, a separate file. Take the third step and the button finally works.
// no directive
latest commit b422a06
fix(deps): update patch updates (#406)28 Aug 2026, 01:44:10 UTC
Fetched from api.github.com in node v24.18.0 at 03:47:27 UTC, then cached — re-served to every visitor until the entry revalidates or a deploy replaces it.
- runs in Node: databases, secrets, the filesystem
- ships 0 kB of JavaScript
- renders once, returns HTML, and is gone
the dashed line is where this file lives · the server, like every file that doesn’t say otherwise
card.tsx · start, crossed, split
// 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>;
}
// step 1 — add a copy button. useState in this file stops the build:
// "This React Hook only works in a Client Component."
// card.tsx — after step 2. The directive went on top of everything
// the file already was, and the build stops again:
// "It is not allowed to define inline "use cache" annotated
// functions in Client Components."
"use client";
import { cacheLife } from "next/cache";
import { useState } from "react";
async function getLatestCommit() {
"use cache"; // ⨯ no client form — this line is the second refusal
cacheLife("hours");
/* … */
}
// card.tsx — after step 3. No directive again: 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
It’s either cached or it isn’t.
We believed it, too — hit or miss, there or not. But “cached” is not a state a page is in; it is a bake with a lifespan. use cache bakes a component’s output into the page’s static shell — one copy, served to everyone — and a cache tag is the handle you pull to throw that copy away. Pulling it empties the shelf and lights no oven. The fresh page is baked when the next request asks for one, and not a moment before.
The stamp below is that copy — this page’s own cache entry, wearing a six-character fingerprint so you can tell one bake from the next. Press the button and a fresh bake lands for every visitor, in the time it takes the label to change back. It feels like one event.
It is three. Flip the switch and run it again in slow motion — the panel narrates each event as it happens. Watch the color: it changes twice, not once, and that gap is what your cache logs are naming. Press the button here and the next request logs REVALIDATED — reason: tag-based deletion — because that request was the refill. STALE is the same gap handled softly: the old bake served while a fresh one is in the oven.
bake #5d7a19
- baked
- 28 Aug 2026, 03:47:26 UTC
- served
- from the static shell — the same entry every visitor gets
throws this page’s cache entry away and bakes 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
I’ll fetch it all first, then render.
Not any more. The static shell ships immediately, and each slow part leaves behind a fallback — the gray placeholder you’ll watch below. As each part finishes, the server streams its finished HTML down the same response, and the placeholder gives way. The fast parts don’t wait for the slow ones.
These three rows are slow on purpose. The delays are hardcoded — the only faked thing on this page — but the streaming is not: each row is a Server Component that genuinely finishes on the server and lands when it is done. Run it again and watch the order hold. What you are seeing is the server finishing, not an animation pretending to.
cooking (~400 ms)
cooking (~1100 ms)
cooking (~1900 ms)
run #0 · via ?stream= in the URL
slow-row.tsx
// slow-row.tsx — genuinely slow, on the server, per request
export async function SlowRow({ delayMs, label }) {
await connection(); // request-time work starts here
await sleep(delayMs);
return <Row label={label} landedAt={new Date()} />;
}
// in the page — the shell ships instantly, rows land when done.
// A new run id makes new boundaries, so the fallbacks show again.
<Suspense fallback={<RowSkeleton />} key={`run-${run}-${row.label}`}>
<SlowRow delayMs={row.delayMs} label={row.label} />
</Suspense>The mutationserver actions & cookies
You need an API route for that.
You need a function. A Server Action lives 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. Press the button below and follow the trip: the form calls the function, the function adds one, and Next.js re-renders the page around the new number.
This one keeps its count in a cookie your browser carries but your JavaScript cannot open — that is what httpOnly means — and a Server Component reads it back. The JavaScript in your tab never touches the value, and could not if it tried.
asking the server for your cookie…
actions.ts + press-form.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);
jar.set("playground-presses", String(count + 1), { 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.
}
// press-form.tsx — the entire frontend
const [, formAction, pending] = useActionState(pressTheButton, null);
return <form action={formAction}>{/* a button */}</form>;The imageimageresponse & route handlers
I’ll need to design a card for every page.
You’ll design one. ImageResponse turns JSX — the same markup your components are made of — into a PNG the moment a request asks, and it is a route handler like any other: query in, image out. One file draws the card for every page you will ever publish.
Type a title and the server draws it. The same endpoint drew the link preview for this page — paste the URL into Slack and check us against it.
GET /api/og?title=The%20unofficial%20live%20playground%20for%20Next.js%20%26%20Vercel
JSX → Satori (flexbox only) → PNG · rendered per request, cached by nobody
app/api/og/route.tsx
// app/api/og/route.tsx — a PNG factory disguised as a route
import { ImageResponse } from "next/og";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const title = clampTitle(searchParams.get("title"));
const fonts = await loadFonts(); // real .ttf files, read once
return new ImageResponse(
<div style={{ display: "flex", backgroundColor: "#071e26" }}>
{title}
</div>,
{
width: 1200,
height: 630,
fonts: [{ name: "JetBrains Mono", data: fonts.bold, weight: 700 }],
}
);
}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 weekly build
One new chapter every Monday.
A new chapter lands in the lab. The write-up lands in your inbox: what it shows, why it matters, when to reach for it.
Cohort seats open to the list first.
One email a week. Unsubscribing 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.