Next.js App Router skill for Claude Code
Layouts, caching and invalidation that behave in prod. Before writing data code, Claude checks the installed Next.js version and whether Cache Components is enabled, because caching defaults differ across 14, 15 and 16. It then keeps pages server-first, tags every cached source, invalidates after each Server Action, and verifies the result in the next build route table and a production server, not in dev mode where caching is hidden.
- name
nextjs-app-router - size225 lines · ≈2,450 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Builds and debugs Next.js App Router applications (Next 15 and 16) with correct layouts, server/client boundaries, data fetching, caching with use cache or fetch revalidation, tag-based invalidation, route handlers, proxy/middleware, and metadata. Use when creating app/ routes, a page shows stale or unexpectedly dynamic data, revalidatePath or revalidateTag does nothing, params typing breaks after an upgrade, or when adding layout.tsx, loading.tsx, error.tsx, route.ts, or generateMetadata. Try asking
- My Next.js page still shows old data after the form saves — fix it
- Upgrade this app from Next 15 to 16 and sort out the params and caching changes
- Add a /dashboard/[teamId] route with a loading state, error boundary and metadata
Use it when
- Creating or restructuring routes, layouts or route groups under app/
- A page is stale after a mutation or is dynamic when it should be static
- Upgrading between Next.js majors where caching or params changed
- Adding route handlers, proxy.ts redirects or generateMetadata
Skip it when
- The question is about RSC serialization or Server Action security in general — use react-server-components
- The issue is Vercel project settings or env vars per environment — use vercel-deploys
How to check the result
Run next build and confirm each route is static, partially prerendered or dynamic as intended, then exercise mutations against next start and see fresh data after invalidation.
The common mistake it prevents
Testing caching in next dev, which re-renders every request, so a missing revalidateTag only shows up after deploy.
A well-built App Router app renders on the server by default, pushes "use client" to
the leaves, states its caching intent explicitly for every data source, invalidates by
tag after every mutation, and never relies on caching defaults it has not checked for
the installed version.
Step 0 — establish the version and caching model
Caching rules changed in every major. Check before writing any data code:
npm ls next react # Next 15.x vs 16.x
grep -n "cacheComponents\|dynamicIO\|ppr\|staleTimes" next.config.*
ls middleware.ts proxy.ts src/middleware.ts src/proxy.ts 2>/dev/null
| Setup | Caching model |
|---|---|
Next 16 with cacheComponents: true | Everything is dynamic unless marked "use cache"; uncached data must sit under <Suspense>; cacheLife/cacheTag control lifetime; segment config dynamic/revalidate not used |
Next 15, or 16 without cacheComponents | fetch is not cached by default; opt in with next: { revalidate, tags } or force-cache; segment config (export const revalidate, dynamic) applies |
| Next 14 or older | fetch cached by default — read the project’s docs version; do not apply this skill’s defaults blindly |
For the full caching matrix, invalidation APIs, and migration notes read references/caching-and-revalidation.md whenever a task touches caching, revalidation, or “stale data” bugs.
File conventions
app/
layout.tsx # root layout: <html>, <body>, providers; persists across navigation
page.tsx # route UI; Server Component by default
loading.tsx # Suspense fallback for the segment
error.tsx # "use client"; error boundary for the segment (receives error, reset)
not-found.tsx # rendered by notFound()
global-error.tsx # catches errors in the root layout; must render <html><body>
(marketing)/ # route group: organizes without affecting the URL
dashboard/
layout.tsx # nested layout; does NOT re-render on child navigation
@analytics/ # parallel route slot; needs default.tsx (required in 16)
[id]/page.tsx # dynamic segment
api/webhooks/route.ts # route handler (no page.tsx in the same segment)
proxy.ts # Next 16 (was middleware.ts; middleware still works but is deprecated)
Workflow for a new route or feature
- [ ] 1. Sketch the segment tree: which layouts persist, where loading/error boundaries go
- [ ] 2. Fetch data in Server Components (page/layout), close to where it is used
- [ ] 3. Declare caching intent for each data source (cached + tag, or dynamic)
- [ ] 4. Mark only interactive leaves "use client"; pass serializable props
- [ ] 5. Mutations via Server Actions (or route handlers for external callers)
- [ ] 6. Invalidate tags/paths after every mutation
- [ ] 7. Add metadata, loading and error states, notFound()
- [ ] 8. next build — read the route table (static vs dynamic) and fix surprises
Pages, params, and types
params and searchParams are Promises since Next 15 (sync access removed in 16).
cookies(), headers(), and draftMode() are async too.
// app/blog/[slug]/page.tsx
import { notFound } from "next/navigation";
export default async function Page({ params }: PageProps<"/blog/[slug]">) {
const { slug } = await params;
const post = await getPost(slug);
if (!post) notFound();
return <Article post={post} />;
}
export async function generateStaticParams() {
return (await listPosts()).map((p) => ({ slug: p.slug }));
}
PageProps<"/route"> / LayoutProps<"/route"> are generated globally by
next dev, next build, or next typegen (15.5+). If unavailable, type as
{ params: Promise<{ slug: string }> }. Upgrade sync access with the codemod:
npx @next/codemod@latest upgrade latest.
Server/client boundary rules
- Default to Server Components. Add
"use client"only for state, effects, event handlers, browser APIs, or client-only libraries. - The directive marks a boundary: everything imported into a client file becomes client code. Put it on the smallest interactive component, not on a page or layout.
- Pass Server Components into client components as
childrenor other props to keep them on the server. - Import
server-onlyin modules that read secrets or the database so an accidental client import fails the build. - Only
NEXT_PUBLIC_*env vars reach the browser, and they are inlined at build time — changing them requires a rebuild. - Deeper RSC rules (serialization, Server Action security, streaming) live in
react-server-components.
Data fetching
- Fetch in parallel; sequential
awaits in one component create waterfalls:
const [user, invoices] = await Promise.all([getUser(id), getInvoices(id)]);
- Deduplicate non-fetch calls used by both
generateMetadataand the page with Reactcache():
import { cache } from "react";
export const getPost = cache(async (slug: string) => db.post.findUnique({ where: { slug } }));
- Stream slow parts: wrap them in
<Suspense fallback={...}>so the shell renders first. - Never call your own route handlers from Server Components (
fetch("/api/...")); call the data function directly. It saves a network hop and avoids absolute-URL bugs at build time.
Mutations
// app/posts/actions.ts
"use server";
import { revalidateTag } from "next/cache";
import { redirect } from "next/navigation";
export async function publishPost(formData: FormData) {
const session = await auth(); // authorize inside every action
if (!session) throw new Error("Unauthorized");
const id = String(formData.get("id"));
await db.post.update({ where: { id, authorId: session.userId }, data: { published: true } });
revalidateTag("posts", "max"); // Next 16 signature; Next 15: revalidateTag("posts")
redirect(`/posts/${id}`); // throws; call outside try/catch
}
- In Next 16, use
updateTag("posts")inside Server Actions when the user must see their own write immediately (read-your-writes);revalidateTag(tag, "max")serves stale while refreshing in the background. redirect()andnotFound()work by throwing; never wrap them intry/catchthat swallows the error.- Use route handlers (
route.ts) for webhooks, third-party callers, file streams, and non-React clients; use Server Actions for your own forms and buttons.
Route handlers
// app/api/webhooks/stripe/route.ts
export async function POST(request: Request) {
const body = await request.text(); // raw body for signature checks
const sig = request.headers.get("stripe-signature");
// verify, then process idempotently
return Response.json({ received: true });
}
GET handlers are dynamic by default from Next 15. Opt a GET into static with
export const dynamic = "force-static" (without cacheComponents) or "use cache" in
a helper (with it).
Proxy (middleware)
- Next 16 renames
middleware.tstoproxy.tswith an exportedproxyfunction; it runs on the Node.js runtime. Codemod:npx @next/codemod@latest middleware-to-proxy . - Keep it thin: redirects, rewrites, locale detection, coarse auth gating via cookie presence. Always re-check authorization in the page, action, or handler — proxy is not a security boundary on its own.
- Scope it with
export const config = { matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"] }.
Metadata
export async function generateMetadata({ params }: PageProps<"/blog/[slug]">): Promise<Metadata> {
const post = await getPost((await params).slug); // deduped by cache()
return { title: post?.title, alternates: { canonical: `/blog/${post?.slug}` } };
}
Set metadataBase in the root layout so relative OG URLs resolve. Use file
conventions for opengraph-image.tsx, sitemap.ts, robots.ts, icon.png.
Gotchas
useSearchParams()in a client component on a static route needs a<Suspense>boundary or the build bails out of static rendering for the whole page.- Layouts do not receive
searchParamsand do not re-render on navigation between sibling pages — do not read per-page state there. error.tsxdoes not catch errors in the layout of the same segment; place it one level up or useglobal-error.tsxfor the root.- A single
cookies()orheaders()call makes the route dynamic (or, with Cache Components, requires a Suspense boundary). Read them as deep in the tree as possible. - Parallel route slots without
default.tsx404 on hard refresh (and fail the build in 16). next lintis removed in 16 — runnpx eslint .directly.- Turbopack is the default bundler in 16; custom webpack config needs migration or
next build --webpack.
Verification
npx next buildsucceeds. Read the route table: each route is static, ISR, or dynamic as you intended. Investigate every unexpected dynamic route.npx next start(not dev — dev does not cache like production) and exercise the mutation: the changed data appears after the action, and a second browser session sees it after revalidation.- Check the network tab: no client-side waterfall of requests that the server could have done, no secrets in RSC payloads.
- Type-check with
npx tsc --noEmit.
Output when reporting changes
**Routes touched:** /dashboard (dynamic), /blog/[slug] (static, tag `posts`)
**Caching:** getPost → "use cache" + cacheTag("posts") + cacheLife("days")
**Invalidation:** publishPost → updateTag("posts")
**Client boundaries:** LikeButton, CommentForm (everything else server)
**Verified:** next build route table matches; mutation visible after action in next start Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
references/caching-and-revalidation.md
# Caching and revalidation in the App Router
Read this when a task involves cached data, ISR, invalidation after mutations, or a
"page shows stale data" / "page is unexpectedly dynamic" bug. Confirm the version and
`cacheComponents` flag first (SKILL.md step 0).
## The layers
| Layer | Where | What it stores | How it is cleared |
|---|---|---|---|
| Request memoization | Server, one render | Identical `fetch` calls and `cache()`-wrapped functions | Ends with the request |
| Data cache / `"use cache"` entries | Server, persistent | Results of cached fetches or cached functions | `cacheLife` expiry, `revalidateTag`, `updateTag`, `revalidatePath` |
| Full route cache | Server, persistent | Prerendered HTML + RSC payload for static routes | Rebuild, or revalidation of the data it depends on |
| Router cache | Browser memory | RSC payloads of visited/prefetched segments | `router.refresh()`, Server Action invalidation, navigation after `staleTimes` |
Most "stale data" bugs are one of: the data cache was never invalidated, the wrong tag
was invalidated, or the browser router cache served an old payload.
## Model A — Cache Components (Next 16, `cacheComponents: true`)
```ts
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = { cacheComponents: true };
export default nextConfig;
```
Rules:
- Nothing is cached unless marked. Any data access that is not cached must be inside
a `<Suspense>` boundary, otherwise `next build` fails with an error telling you
which component accessed uncached data.
- Mark a function, component, or whole file with `"use cache"`:
```ts
import { cacheLife, cacheTag } from "next/cache";
export async function getProducts(category: string) {
"use cache";
cacheLife("hours"); // built-in profiles: seconds, minutes, hours, days, weeks, max
cacheTag("products", `category:${category}`);
return db.product.findMany({ where: { category } });
}
```
- Arguments and closed-over values become part of the cache key; they must be
serializable. Do not pass request objects or class instances.
- You cannot call `cookies()`, `headers()`, or read `searchParams` inside a
`"use cache"` scope. Read them outside and pass the needed primitive values in.
- Route segment config (`export const dynamic`, `revalidate`, `fetchCache`) is not used
in this model; replace each with `"use cache"` + `cacheLife` or a Suspense boundary.
- Custom profiles go in `next.config.ts` under `cacheLife: { editorial: { stale, revalidate, expire } }`
(values in seconds).
Static shell + dynamic holes (partial prerendering) comes for free: the cached parts
prerender, Suspense fallbacks render in the shell, and dynamic parts stream in.
## Model B — fetch options and segment config (Next 15, or 16 without the flag)
```ts
// cached for an hour, tagged
await fetch("https://api.example.com/products", { next: { revalidate: 3600, tags: ["products"] } });
// cached until invalidated
await fetch(url, { cache: "force-cache", next: { tags: ["settings"] } });
// never cached (default in 15+)
await fetch(url, { cache: "no-store" });
```
Non-fetch data sources (ORM, SDK) in this model: wrap with `unstable_cache(fn, keyParts, { tags, revalidate })`,
or accept that they are dynamic. Segment-wide defaults:
```ts
export const revalidate = 3600; // ISR for the segment
export const dynamic = "force-static"; // or "force-dynamic"
export const dynamicParams = false; // 404 for slugs not returned by generateStaticParams
```
## Invalidation APIs
| API | Where callable | Semantics |
|---|---|---|
| `revalidateTag(tag, profile)` | Server Actions, route handlers | Marks tagged entries stale; next request serves stale and refreshes in background (stale-while-revalidate). Next 16 requires the second argument (`"max"` recommended); Next 15 takes only the tag |
| `updateTag(tag)` | Server Actions only (16) | Expires immediately; the same request's re-render reads fresh data. Use after a user's own write |
| `revalidatePath(path, type?)` | Server Actions, route handlers | Invalidates everything rendered under a path; `type: "layout"` includes nested routes. Blunt — prefer tags |
| `refresh()` from `next/cache` | Server Actions (16) | Refreshes uncached dynamic data on the current page without touching cache entries |
| `router.refresh()` | Client | Re-fetches the current route's RSC payload; does not clear server caches |
Tag design:
- One broad tag per entity type (`products`) plus one narrow tag per record
(`product:123`). Invalidate the narrow tag after a single-record edit and the broad
tag after create/delete.
- Keep tag strings in one module (`tags.ts`) so writers and readers cannot drift.
- External changes (CMS publish, admin tool) call a route handler that verifies a
secret and then calls `revalidateTag`.
```ts
// app/api/revalidate/route.ts
import { revalidateTag } from "next/cache";
export async function POST(req: Request) {
if (req.headers.get("x-revalidate-secret") !== process.env.REVALIDATE_SECRET) {
return new Response("Forbidden", { status: 403 });
}
const { tag } = await req.json();
revalidateTag(tag, "max");
return Response.json({ revalidated: tag });
}
```
## Router cache (browser)
- Since Next 15, page segments are not reused from the router cache on navigation by
default (`staleTimes.dynamic` = 0); layouts and loading states are still reused.
Back/forward navigation restores from cache.
- Server Actions that call `revalidateTag`/`revalidatePath`/`updateTag` also refresh
the client router cache for the current user.
- Changes made by other users only reach a client on its next navigation or
`router.refresh()`.
## Debugging stale or dynamic routes
1. Reproduce with `next build && next start`. Dev mode re-renders on every request
and hides caching behavior.
2. Read the build route table: `○` static, `◐` partial prerender, `ƒ` dynamic.
An unexpected `ƒ` usually means `cookies()`, `headers()`, `searchParams`,
`connection()`, or an uncached fetch upstream.
3. Log on the server inside the data function. If it does not log on reload, the
result is cached; if it logs on every reload, it is dynamic.
4. Compare the tag string used in `cacheTag`/`fetch` with the one used in
invalidation, character by character.
5. Self-hosting with multiple instances: the default cache is per-instance on disk.
Configure a shared cache handler, or invalidation only reaches one instance.
## Migration notes
- 14 → 15: add explicit caching to fetches that relied on the old default; convert
sync `params`/`cookies()` to `await`.
- 15 → 16: `experimental.dynamicIO`/`experimental.ppr` become `cacheComponents`;
add the profile argument to `revalidateTag`; rename `middleware.ts` to `proxy.ts`;
add `default.tsx` to parallel route slots; replace `next lint` with ESLint.
- Run `npx @next/codemod@latest upgrade latest` first, then fix remaining type errors.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.