An opinionated, deep-dive reference for Next.js App Router — RSC mental models, routing patterns, caching semantics, Server Actions, Proxy, and production patterns. Based on Next.js 16. Not a tutorial. A reference you return to.
This is not a "getting started" guide. The official docs handle that well. This is the reference I wish existed when I moved from Pages Router to App Router — and again when Next.js 16 changed the caching model entirely.
Every section answers one question: how do I actually use this in production?
Covers Next.js 16 / React 19. If you're on Next.js 14–15, the caching section will differ. Check the Previous Model guide for the old fetch() semantics.
Before touching any API, you need one mental model that explains everything else.
App Router is a server-first framework. Every component is a Server Component by default. You opt into the client.
This is the opposite of what you're used to. In Pages Router (and React before RSC), everything was client-side unless you explicitly fetched on the server. Now the server is the default — the client is the exception.
Server World Client World
───────────────────────────── ─────────────────────────────
Runs on Node.js / Edge Runs in the browser
Can read DB, env, filesystem Can access window, DOM, localStorage
No useState, useEffect Full React hooks available
Cannot handle user events Can handle onClick, onChange
Renders once per request Re-renders on state change
These two worlds cannot directly mix. A Server Component can import and render a Client Component. A Client Component cannot import a Server Component — but it can accept one as children (the composition pattern).
Does it need interactivity?
├── No → Server Component (default)
│ Does it fetch data?
│ ├── Yes → fetch() or DB query directly in the component
│ └── No → pure UI, still server
└── Yes → useState / useEffect / event handlers?
├── Yes → 'use client'
└── Maybe → try Server Component first,
extract only the interactive part as Client Component
The goal is to push 'use client' as deep as possible. A page can be a Server Component that renders one small <LikeButton> Client Component. That's ideal — not "this page uses state so the whole thing is client."
'use client' is a module boundary marker, not a component attribute. When you add it to a file, you're saying: "this module and everything it imports runs on the client."
This has a critical implication: if a Server Component imports a module that imports a Client Component, the entire import chain becomes client code. This is how you accidentally ship server-only code paths to the browser.
// ❌ This will error — server-only module imported in client file'use client'import { db } from '@/lib/database' // uses Node.js APIs// ✅ Keep data fetching in the server, pass results as props// Server Componentimport { db } from '@/lib/database'import { ClientWidget } from './ClientWidget'export default async function Page() { const data = await db.query(...) return <ClientWidget initialData={data} />}
Use server-only to enforce this at build time:
// lib/database.tsimport 'server-only' // Build error if imported in client bundleexport const db = ...
Next.js handles server-only imports internally — the package content from npm isn't used, but the constraint is enforced.
User navigates A → B → A
Layout: [A renders once, persists through B, back to A — no remount]
Template: [A mounts] → [A unmounts, B mounts] → [B unmounts, A mounts fresh]
Use layout for: navigation bars, sidebars, anything that should maintain state across routes.
Use template for: page transition animations, analytics pageview events that need to fire on every navigation, intentionally resetting component state.
// app/layout.tsx — persists, state survives navigationexport default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html> <body> <Sidebar /> {/* renders once */} {children} </body> </html> )}// app/template.tsx — re-creates on every navigation'use client'import { motion } from 'framer-motion'export default function Template({ children }: { children: React.ReactNode }) { return ( <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }}> {children} </motion.div> )}
Intercepting routes let you load a route inside the current layout. The canonical example: opening a photo in a modal while keeping the feed visible, but the photo is shareable via direct URL.
app/
├── feed/page.tsx
└── photo/
├── [id]/page.tsx → /photo/123 (full page — direct URL)
└── (..)photo/[id]/page.tsx → intercepts /photo/123 when navigating from feed
Convention:
(.) — same level
(..) — one level up
(..)(..) — two levels up
(...) — from root
Pair with parallel routes for the Instagram-style photo modal pattern.
Next.js 16 with Cache Components enabled uses Partial Prerendering (PPR) as the default. Every route generates a static shell at build time, and dynamic holes stream in at request time via Suspense boundaries.
Static shell (built at compile time, served from edge)
└── <Suspense> boundaries = "dynamic holes"
└── Streams from server per request
How each component is handled during build:
What the component does
Build behavior
'use cache' directive
Cached, included in static shell
Pure computations, module imports
Automatically included in static shell
<Suspense> wrapping dynamic content
Fallback in static shell, content streams at request time
// next.config.tsimport type { NextConfig } from 'next'const nextConfig: NextConfig = { cacheComponents: true,}export default nextConfig
With this enabled, Next.js will throw a build error if you access uncached data outside a <Suspense> boundary: Uncached data was accessed outside of <Suspense>.
Streaming lets you progressively render the UI. Send the static shell immediately, stream dynamic parts as they resolve.
// Without streaming — user sees nothing until ALL data fetchedexport default async function Dashboard() { const [user, analytics, activity] = await Promise.all([ getUser(), getAnalytics(), // slow — 2s getActivity(), // slow — 1.5s ]) return <DashboardUI user={user} analytics={analytics} activity={activity} />}// With streaming — layout appears immediately, data streams inimport { Suspense } from 'react'export default async function Dashboard() { const user = await getUser() // fast — needed for layout return ( <DashboardLayout user={user}> <Suspense fallback={<AnalyticsSkeleton />}> <Analytics /> {/* fetches its own data, streams in when ready */} </Suspense> <Suspense fallback={<ActivitySkeleton />}> <Activity /> {/* independent — doesn't block Analytics */} </Suspense> </DashboardLayout> )}async function Analytics() { const data = await getAnalytics() // 2s — doesn't block Activity return <AnalyticsChart data={data} />}
loading.tsx is syntactic sugar for a Suspense boundary around your page.tsx. Use loading.tsx for route-level loading states. Use <Suspense> inline for component-level granularity.
This is the most complex part of App Router — and in Next.js 16, the model changed significantly. The old fetch() cache semantics are now the "Previous Model." The new model uses the use cache directive.
Short-lived caches (seconds profile, revalidate: 0, or expire < 5 minutes) are automatically excluded from prerenders and become dynamic holes instead.
Stale-while-revalidate — serve stale immediately, fresh in background
Immediately expires cache — user sees fresh on next request
Use case
Blog posts, product catalogs — slight delay is acceptable
User's own data — read-your-own-writes
import { revalidateTag, updateTag } from 'next/cache'// After admin publishes a blog post — background refresh is fineexport async function publishPost(id: string) { 'use server' await db.posts.publish(id) revalidateTag('posts', 'max') // 'max' = stale window duration}// After user updates their profile — they should see their change immediatelyexport async function updateProfile(data: ProfileData) { 'use server' await db.profiles.update(data) updateTag('profile') // immediately expires, next request is fresh redirect('/profile')}
refresh() refreshes the client router. revalidatePath() / revalidateTag() invalidate cached data. Use both together when you need the cache cleared AND the UI refreshed.
Server Actions are async functions that run on the server, called from the client. They replace API routes for mutations.
Security: Server Actions are reachable via direct POST requests — not just through your UI. Always verify authentication and authorization inside every action.
In Next.js 16, middleware.ts was renamed to proxy.ts to better reflect its purpose. The functionality is the same — it runs on the Edge Runtime before every request. The export is named proxy (or default export).
Note: Third-party libraries may still refer to this as middleware. Internally, it's proxy from Next.js 16 onward.
// proxy.ts — must be in project root (or /src)import { NextResponse } from 'next/server'import type { NextRequest } from 'next/server'export function proxy(request: NextRequest) { return NextResponse.next()}// Alternatively, default exportexport default function proxy(request: NextRequest) { return NextResponse.next()}// Only run on specific pathsexport const config = { matcher: [ '/dashboard/:path*', '/api/:path*', // Skip static files and Next.js internals '/((?!_next/static|_next/image|favicon.ico).*)', ],}
The most common use case — redirect unauthenticated users. Use Proxy for optimistic checks only. Don't do database lookups here — it runs on every request including prefetches.
// proxy.tsimport { NextResponse } from 'next/server'import type { NextRequest } from 'next/server'import { decrypt } from '@/lib/session'const protectedRoutes = ['/dashboard', '/settings', '/api/protected']const publicRoutes = ['/login', '/register', '/']export async function proxy(req: NextRequest) { const path = req.nextUrl.pathname const isProtectedRoute = protectedRoutes.some(r => path.startsWith(r)) const isPublicRoute = publicRoutes.includes(path) // Read session from cookie — optimistic check, no DB lookup const cookie = req.cookies.get('session')?.value const session = await decrypt(cookie) // just JWT verification, not DB if (isProtectedRoute && !session?.userId) { const loginUrl = new URL('/login', req.url) loginUrl.searchParams.set('callbackUrl', path) return NextResponse.redirect(loginUrl) } if (isPublicRoute && session?.userId && !path.startsWith('/dashboard')) { return NextResponse.redirect(new URL('/dashboard', req.url)) } // Inject user info into headers for downstream use const response = NextResponse.next() if (session?.userId) { response.headers.set('x-user-id', session.userId) response.headers.set('x-user-role', session.role ?? '') } return response}export const config = { matcher: ['/((?!api|_next/static|_next/image|favicon.ico|.*\\.png$).*)'],}
Reading injected headers in a Server Component:
// app/dashboard/page.tsximport { headers } from 'next/headers'export default async function DashboardPage() { const headersList = await headers() const userId = headersList.get('x-user-id') // No need to verify token again — Proxy already did it const user = await getUserById(userId!) return <Dashboard user={user} />}
❌ Node.js built-ins (fs, crypto, path, child_process)
❌ Most npm packages that depend on Node.js APIs
❌ Packages over ~1MB
✅ Web APIs (fetch, URL, Headers, Request, Response)
✅ WebCrypto (SubtleCrypto)
✅ jose (JWT), zod, nanoid
✅ next/headers, next/cookies (with limitations)
If you need Node.js APIs for auth, verify only the JWT signature in Proxy (lightweight), and do the full session lookup in the Server Component or Route Handler.
Performance
☐ next/image for all images (width, height, alt, priority on above-fold)
☐ next/font for all custom fonts
☐ Dynamic imports for components >50KB
☐ Bundle analyzed — no large surprise packages in client bundle
☐ Core Web Vitals green in Lighthouse
Security
☐ Security headers configured in next.config.ts
☐ All Server Actions authenticate + authorize (not just check login)
☐ Resource ownership checked (user owns what they're mutating)
☐ No secrets in NEXT_PUBLIC_ env vars
☐ Rate limiting on auth endpoints and expensive operations
☐ CSP configured (at minimum default-src 'self')
Caching
☐ User-specific data never in 'use cache' without sessionId in cache key
☐ Mutations call revalidatePath / revalidateTag / updateTag
☐ Runtime APIs (cookies, headers) inside Suspense boundaries
☐ cacheLife profiles match content freshness requirements
SEO
☐ generateMetadata on all public pages
☐ OG images configured (1200x630)
☐ sitemap.ts / robots.ts present
☐ Canonical URLs set
Reliability
☐ error.tsx on all critical segments
☐ not-found.tsx at root
☐ Loading states for all async content
☐ Env vars validated at startup (fail fast)
☐ unstable_instant on key navigation routes
Is data user-specific or requires cookies/headers?
├── Yes → Runtime data — inside <Suspense>, no 'use cache'
└── No → Can it be stale for some time?
├── No → Inside <Suspense>, no 'use cache'
└── Yes → 'use cache' + cacheLife(...)
└── Does it change based on mutations?
├── Yes → cacheTag(...) + revalidateTag/updateTag on mutation
└── No → cacheLife('max') or 'weeks'
Last updated: April 2026. Covers Next.js 16.x / React 19.