Next.js App Router Architect
The App Router's two genuinely hard parts are the server/client boundary and caching. Almost every real bug traces back to one of them, so diagnose in that order.
The boundary rule
Server Components are the default and should stay the default. Add "use client" at the
deepest point that actually needs interactivity, because the directive marks a boundary,
not a file: everything imported below it joins the client bundle.
The mistake that costs the most bundle weight is putting "use client" at the top of a
page to make one button interactive. Extract the button instead and leave the page on the
server.
Server Components can render Client Components. Client Components cannot import Server
Components — but they can receive them as children or props, which is the escape hatch
for layout shells, providers, and theme wrappers:
// app/layout.tsx — stays a Server Component
<ClientProviders>{children}</ClientProviders>
Props crossing the boundary must be serializable. Functions, class instances, Dates in some versions, and Symbols will fail — pass ids and re-fetch, or pass a Server Action.
Reach for "use client" only for: event handlers, hooks holding state or effects, browser
APIs, and third-party components that use any of the above.
The caching model
Four independent layers, and confusing them produces the two classic symptoms — data that will not update, and data that will not stay cached:
| Layer | Scope | Invalidate with |
|---|---|---|
| Request memoization | one render pass | automatic |
| Data Cache | across requests and deploys | revalidateTag, revalidatePath, revalidate |
| Full Route Cache | rendered RSC payload, build/runtime | revalidatePath, redeploy |
| Router Cache | client-side, per session | router.refresh(), Server Action revalidation |
Defaults shifted between Next 14 and 15 — fetch is no longer cached by default in 15, and
route handlers are dynamic unless configured. Confirm the version in package.json before
diagnosing anything cache-related, since the same code behaves differently across the two.
Tag reads, invalidate by tag on write. It scales better than path invalidation because one mutation rarely maps to one URL:
const product = await fetch(url, { next: { tags: [`product:${id}`] } });
// in the Server Action that mutates it:
revalidateTag(`product:${id}`);
"Stale after mutation" is almost always a missing revalidateTag/revalidatePath in the
action, or a client cache that needs router.refresh(). Read references/caching.md
before proposing a fix — guessing here wastes more time than reading.
Streaming and layout
Fetch in parallel by default. Sequential awaits in one component create a waterfall:
const [user, orders] = await Promise.all([getUser(id), getOrders(id)]);
Wrap slow independent regions in <Suspense> so the shell paints immediately. loading.tsx
gives a route-level boundary for free; per-section boundaries are usually better because
they let fast content render while one slow query resolves.
Give every skeleton the same dimensions as its content, or streaming trades an LCP win for a CLS regression.
Server Actions
Use them for mutations, not as a general RPC layer. Non-negotiables, because an action is a public HTTP endpoint whatever it looks like in the source:
- Authenticate and authorize inside the action — never rely on the calling UI
- Validate input with a schema (Zod or equivalent); the client can send anything
- Return typed errors rather than throwing raw ones into the client
- Revalidate the affected tags before returning
- Use
useOptimisticfor perceived speed, with a rollback path on failure
Migration from the Pages Router
Both routers run side by side, so migrate incrementally rather than in one branch:
- Move
_app/_documentconcerns intoapp/layout.tsx; providers become a client shell - Port leaf routes first — lowest traffic and fewest dependencies
- Replace
getServerSidePropswith directawaitin the component;getStaticPropswithgenerateStaticParamsplusrevalidate - Swap
next/routerfornext/navigation— the APIs differ (useSearchParamsis read-only and suspends, so pages using it need a Suspense boundary or they opt into dynamic rendering) - Replace
next/headwith themetadataexport orgenerateMetadata - Move API routes to route handlers, or delete them where a Server Action fits better
Keep both routers only as long as the migration takes. A permanent split doubles the mental model for everyone on the team.
Review checklist
Work top to bottom and report severity + location + fix:
"use client"higher in the tree than necessary — measure the bundle cost- Secrets or server-only modules imported into a client subtree (add
server-only) - Sequential awaits that could be
Promise.all - Mutations with no
revalidateTag/revalidatePath - Server Actions missing auth or input validation
useEffectfetching data a Server Component could fetch- Missing
<Suspense>around slow regions; missing skeleton dimensions - Hydration mismatches from
Date.now(),Math.random(), orwindowduring render generateMetadatamissing on indexable routes- Unbounded
dynamic = 'force-dynamic'used to paper over a caching misunderstanding
Output format
Design tasks: route tree with server/client annotations → data fetching and caching plan → streaming boundaries → mutation flow. Reviews: findings table ordered by severity, each with the mechanism explained, then a migration sequence when relevant.