hasToggle

The unofficial live playground for Next.js and Vercel.

Watch it run.*

For developers who learn by poking things.

Start poking

Nothing on this page is a mockup. We checked twice.

01server & client components

I’ll put “use client” on it, to be safe.

Safer than what? We reached for it the same way, for about a year, before anyone made us say what it was protecting against. 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.

Watch the two cards below. One rendered in Node.js and arrived as finished HTML — done before you got here. The other arrived as JavaScript and woke up in your tab — the waking is called hydration — and its button is waiting for a click. Only one of them is running Node, and it prints the version to prove it.

live
serverserver-card.tsx

Rendered in Node.js v24.19.0

at 20 Aug 2026, 08:07:12 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
clientclient-card.tsx

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

props cross the boundary as serialized data — the import graph decides which side a component runs on.

server-card.tsx + client-card.tsx
// 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>;
}

// 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>
  );
}

02caching & 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.

live

bake #fd8d24

baked
20 Aug 2026, 08:07:12 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 visitor

03streaming & 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.

live

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>

04server 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.

live

asking the server for your cookie…

works with JavaScript switched off — try it, we’ll wait

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>;

05imageresponse & 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.

working

GET /api/og?title=The%20unofficial%20live%20playground%20for%20Next.js%20%26%20Vercel

asking the server for a PNG…
og.png · 1200 × 630open the file ↗

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 exhibit 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 exhibits 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.

Tell me when seats open

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.

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 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 official?

No. Vercel hasn’t endorsed this site, and nobody on the Next.js team sees an exhibit 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 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 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 exhibit and the write-up that goes with it. Five minutes, no filler, and nothing you have to read on a schedule.

© 2026 hasToggle.
Come back Monday. There’ll be something new to poke.
hasToggle is an independent project, not affiliated with or endorsed by Vercel. Next.js and Vercel are trademarks of Vercel, Inc.