Frontend Editor's pick

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.

  • namenextjs-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.

~/.claude/skills/nextjs-app-router/SKILL.md

--- name: nextjs-app-router description: Builds and debugs Next.js App Router applications (Next 15 and 16) with correct layouts, server/client boundaries, data fetching, caching… license: MIT ---

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
SetupCaching model
Next 16 with cacheComponents: trueEverything 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 cacheComponentsfetch is not cached by default; opt in with next: { revalidate, tags } or force-cache; segment config (export const revalidate, dynamic) applies
Next 14 or olderfetch 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 children or other props to keep them on the server.
  • Import server-only in 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 generateMetadata and the page with React cache():
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() and notFound() work by throwing; never wrap them in try/catch that 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.ts to proxy.ts with an exported proxy function; 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 searchParams and do not re-render on navigation between sibling pages — do not read per-page state there.
  • error.tsx does not catch errors in the layout of the same segment; place it one level up or use global-error.tsx for the root.
  • A single cookies() or headers() 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.tsx 404 on hard refresh (and fail the build in 16).
  • next lint is removed in 16 — run npx eslint . directly.
  • Turbopack is the default bundler in 16; custom webpack config needs migration or next build --webpack.

Verification

  1. npx next build succeeds. Read the route table: each route is static, ISR, or dynamic as you intended. Investigate every unexpected dynamic route.
  2. 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.
  3. Check the network tab: no client-side waterfall of requests that the server could have done, no secrets in RSC payloads.
  4. 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 141 lines · 6.8 KB
# 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.

esc