server & client components
Two components walk into a page.
Every component in the App Router runs on the server unless you say otherwise. One directive — "use client" — moves a subtree into the browser. Everything else follows from that split: what can read secrets, what can hold state, what ships JavaScript and what arrives as finished HTML.
One of these cards rendered in Node.js and arrived as finished HTML. The other hydrated in your tab and is waiting for you to click it.
Rendered in Node.js v24.18.0
at 10 Aug 2026, 08:44:20 UTC, then cached. Refresh the page — this card doesn't re-render, it gets re-served.
- can query databases, read secrets
- ships 0 kB of JavaScript
- cannot use state or onClick
0
This button works because this component hydrated in your browser.
- can use state, effects, onClick
- ships JavaScript (that's the deal)
- cannot read secrets — it lives in your tab
the code — server-card.tsx
import { cacheLife } from "next/cache";
// No "use client" at the top: this is a Server Component.
// It runs in Node.js, its output is cached, zero JS ships.
async function getServerFacts() {
"use cache";
cacheLife("hours");
return {
nodeVersion: process.version, // browsers don't have one
renderedAt: new Date().toISOString(),
};
}
export async function ServerCard() {
const facts = await getServerFacts();
return <Card side="server">Rendered in Node.js {facts.nodeVersion}</Card>;
}the code — client-card.tsx
"use client"; // this one directive is the entire boundary
import { useState } from "react";
export function ClientCard() {
const [count, setCount] = useState(0);
return (
<Card side="client">
<button onClick={() => setCount(count + 1)}>Click me</button>
{count}
</Card>
);
}The error that sends everyone here is “useState only works in a Client Component”. Now you know why: the server has no clicks to listen for.
caching & revalidation
This page was baked before you arrived.
Static doesn't mean written by hand, and dynamic doesn't mean every visitor pays full price. use cache bakes a component's output into the page's static shell; a cache tag gives you a handle to expire it on demand.
The stamp below is this page's cache entry. Press the button and it expires — for every visitor, instantly. Nobody ever lets you press this button. Go on.
bake #a771e677
- baked
- 10 Aug 2026, 08:44:20 UTC
- served
- from the static shell — no server render for you
expires the cache tag, re-renders the stamp — for everyone, not just you
the code — bake.ts + actions.ts
// 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, 8), // the fingerprint
};
}
// actions.ts — the mutation
"use server";
export async function rebakeShell() {
updateTag("landing-shell"); // expires it now, for everyone
return { rebakedAt: new Date().toISOString() };
}“Why is my page stale” and “why is my page slow” are the same question read from opposite ends. This demo is both answers.
streaming & suspense
The page refused to wait.
Slow data used to hold the whole page hostage. With Suspense, the static shell ships immediately, every slow part shows its fallback, and the server streams finished HTML into place — over the same response — whenever each part is done.
These three rows are slow on purpose. Watch the skeletons resolve in delay order — fastest first, slowest last. Then make it do it again.
run #0 · via ?stream= in the URL
cooking (~400 ms)
cooking (~1100 ms)
cooking (~1900 ms)
the code — 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>loading.tsx is this exact mechanism wearing route-sized clothes. One file, and your whole segment gets a fallback.
server actions & cookies
A form with no API route.
A Server Action is a function that lives on the server and plugs straight into a form's action. No endpoint to design, no fetch, no JSON contract — the form invokes the function, the function mutates, and Next.js re-renders the page with the result.
This one increments a counter stored in an httpOnly cookie. The number is read back by a Server Component — the JavaScript in your tab never touches it, and couldn't if it tried.
asking the server for your cookie…
the code — 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>;Somewhere, a 2019 tutorial is still teaching you to build /api/increment. It can rest now.
imageresponse & route handlers
An image that didn't exist a second ago.
ImageResponse turns JSX into a PNG at request time — it's how sites generate a link-preview card per page instead of per designer. Under the hood it's a route handler like any other: query in, image out.
Type a title and the server draws your card. The same endpoint makes the link preview for this page — and the pattern carries to every page you'll ever need a card for.
GET /api/og?title=The%20live%20playground%20for%20Next.js%20%26%20Vercel
the code — 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 fine print: it looks like CSS, but the renderer (Satori) only understands flexbox. Grid users will be shown the door, politely, at build time.
The syllabus grows
Five exhibits. Dozens to go.
The plan is everything Next.js has to offer — and as much of the Vercel platform as can be demonstrated from inside a web page. One exhibit at a time, in public.
- dynamic routes & params
- next/image, fonts & the asset pipeline
- proxy, redirects & rewrites
- error, not-found & recovery
- parallel & intercepted routes
- ISR at scale
- edge network & geolocation
- preview deploys & instant rollback
- cron, queues & background work
- blob, KV & 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 exhibits above. The playground shows you the wall. The cohort gets you over it: small paid groups, building production apps on exactly these topics, AI workflow included.
Paid, small, and honest about both.
The weekly build
One new exhibit every Monday.
A new demo lands on this page. 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.
The fact that you're reading the fine print under an email form says something about you. Something good.
Frequently asked questions
Your questions answered.
What exactly am I looking at?
A place to find out what happens when you press things. Each exhibit 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 exhibit at a time. The official docs are good; this is the lab bench that belongs next to them.
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.
See? We told you what we’re selling. Most landing pages hide that part.
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 exhibit, 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 the process — prompts, checkpoints, wrong turns included — is being published alongside via Entire.io. This site is its own biggest demo.
The AI writes the code. The judgment about what ships stays human. That division of labor is the actual curriculum.
What lands in my inbox on Monday?
One new exhibit and its write-up: what it shows, why it matters, when to reach for it. Five minutes, no filler, and unsubscribing stays one click away.