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 96d80d8
fix(deps): update patch updates (#413)30 Aug 2026, 14:54:11 UTC
Fetched from api.github.com in node v24.18.0 at 04:03:20 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 #ec1e61
- baked
- 31 Aug 2026, 04:03:19 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.
You can. But it will be slow. The panel below is that page: a database query, a third-party API, a legacy service, all three awaited before anything is returned. Give it a moment — nothing appears until the slowest of them is back. Then look at the second number on each row. The database query finished in 400 ms and reached you a second and a half later, having waited on a service it never called.
Press step two. A placeholder appears where the blank was — that is a <Suspense> fallback, and a loading.tsx file is one of them wrapped around a whole route segment — and the rows still arrive together, late, in a group. Press step three and each row gets a boundary of its own; each one leaves the server the second it is done. Notice what did not change: the legacy service still costs 1900 ms. But it has stopped charging the other two for it.
The delays are simulated. The streaming is real: each row is a Server Component that finishes on the server, and every arrival time you read was measured rather than written down. Flip response in the corner to see the same run as the server sent it — one response, held open, a chunk per boundary.
one boundary, no fallback · three rows, one arrival · the fastest has to wait for the slowest
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
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.