yigityalim/x/github/hire/share
Back to Handbooks

Next.js App Router Handbook

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.

Last updated: 2026-04-16

Tech Stack

Next.jsReactTypeScriptTailwind CSSSupabaseVerceltRPC

Links

GitHub
PreviousMonorepo Architecture with TurborepoNextCryptography Handbook
© 2026 Yiğit Yalım. All rights reserved.
/

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.


Table of Contents

  • 1. The Mental Model
    • The Two Worlds
    • The Decision Tree
    • What "use client" Actually Means
  • 2. Routing
    • File Conventions
    • Layout vs Template
    • Dynamic Routes
    • Route Groups
    • Parallel Routes
    • Intercepting Routes
  • 3. Rendering
    • The Rendering Model
    • Enabling Cache Components
    • Forcing Dynamic Rendering
    • Opting Into Dynamic Rendering
    • Streaming
  • 4. Data Fetching
    • Server Components
    • Parallel vs Sequential Fetching
    • React cache() for Deduplication
    • Streaming Data with the use API
  • 5. Caching & Revalidation
    • The use cache Directive
    • cacheLife Profiles
    • cacheTag for On-Demand Invalidation
    • revalidateTag vs updateTag
    • revalidatePath
    • refresh() — Refresh Without Revalidating Tags
    • Streaming Uncached Data
    • Runtime APIs Must Be Inside Suspense
    • Passing Runtime Values to Cached Functions
    • The Previous Model (No cacheComponents)
  • 6. Server Actions
    • Basic Pattern
    • Progressive Enhancement with Forms
    • Pending States with useActionState
    • Inline Actions
    • Security: Always Authorize Resource Ownership
    • Server Actions vs Route Handlers
  • 7. Proxy
    • The Basics
    • Auth Pattern
    • Multi-tenant Routing
    • CSP Nonces
    • Edge Runtime Constraints
  • 8. Patterns
    • Full Auth Flow
    • Optimistic UI with useOptimistic
    • URL State Management
    • Multi-tenant Architecture
    • Instant Navigation with unstable_instant
    • Error Boundaries
  • 9. Production Checklist
    • Performance
    • Security Headers
    • Environment Variables
    • Metadata & SEO
    • Dynamic OG Images
    • Pre-deploy Checklist
  • Appendix: Quick Reference
    • File Conventions
    • Caching API Reference
    • Rendering Decision

1. The Mental Model

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.

The Two Worlds

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).

The Decision Tree

When you create a new component, ask:

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."

What "use client" Actually Means

'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 Component
import { 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.ts
import 'server-only' // Build error if imported in client bundle
export const db = ...

Next.js handles server-only imports internally — the package content from npm isn't used, but the constraint is enforced.


2. Routing

File Conventions

App Router uses a file-system convention where folders define routes and files define UI. The key files:

FilePurpose
page.tsxUnique UI for a route segment. Makes the route publicly accessible.
layout.tsxShared UI that wraps child segments. Persists across navigation — does not remount.
template.tsxLike layout, but creates a new instance on every navigation. Use for animations.
loading.tsxAutomatic Suspense boundary. Shown while page loads.
error.tsxError boundary for the segment. Must be 'use client'.
not-found.tsxRendered when notFound() is called from within the segment.
route.tsAPI endpoint. No UI.
proxy.tsEdge function that runs before every request (was middleware.ts before Next.js 16).

Layout vs Template

This distinction trips people up:

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 navigation
export 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>
  )
}

Dynamic Routes

app/
├── blog/[slug]/page.tsx          → /blog/anything
├── shop/[...categories]/page.tsx → /shop/a/b/c (one or more segments)
└── docs/[[...path]]/page.tsx     → /docs AND /docs/a/b/c (zero or more)

In Next.js 15+, params and searchParams are Promises. Always await them:

// app/blog/[slug]/page.tsx
export default async function BlogPost({
  params,
  searchParams,
}: {
  params: Promise<{ slug: string }>
  searchParams: Promise<{ page?: string }>
}) {
  const { slug } = await params
  const { page } = await searchParams
 
  const post = await getPost(slug)
  if (!post) notFound()
 
  return <Article post={post} />
}
 
// Generate static paths at build time
export async function generateStaticParams() {
  const posts = await getAllPosts()
  return posts.map((post) => ({ slug: post.slug }))
}

Or use the globally available PageProps helper (generated by next dev / next build):

export default async function Page(props: PageProps<'/blog/[slug]'>) {
  const { slug } = await props.params
  return <h1>Blog post: {slug}</h1>
}

Route Groups

Route groups (name) organize your routes without affecting the URL.

Multiple root layouts:

app/
├── (marketing)/
│   ├── layout.tsx     → layout for landing pages
│   ├── page.tsx       → /
│   └── about/page.tsx → /about
└── (app)/
    ├── layout.tsx     → layout for authenticated app
    ├── dashboard/page.tsx → /dashboard
    └── settings/page.tsx  → /settings

Shared layout without URL segment:

app/
└── (auth)/
    ├── layout.tsx     → AuthLayout shared by login + register
    ├── login/page.tsx → /login
    └── register/page.tsx → /register

Parallel Routes

Parallel routes render multiple pages simultaneously in the same layout. Each slot has its own loading.tsx and error.tsx.

app/
└── dashboard/
    ├── layout.tsx
    ├── page.tsx
    ├── @analytics/
    │   ├── page.tsx
    │   └── loading.tsx
    └── @activity/
        ├── page.tsx
        └── loading.tsx
// app/dashboard/layout.tsx
export default function DashboardLayout({
  children,
  analytics,
  activity,
}: {
  children: React.ReactNode
  analytics: React.ReactNode
  activity: React.ReactNode
}) {
  return (
    <div className="grid grid-cols-12">
      <main className="col-span-8">{children}</main>
      <aside className="col-span-4">
        {analytics} {/* loads independently */}
        {activity}  {/* loads independently */}
      </aside>
    </div>
  )
}

Always add default.tsx to slots — needed when navigating to routes that don't have a matching page for the slot:

// app/dashboard/@analytics/default.tsx
export default function Default() {
  return null // or a skeleton
}

Intercepting Routes

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.


3. Rendering

The Rendering Model

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 doesBuild behavior
'use cache' directiveCached, included in static shell
Pure computations, module importsAutomatically included in static shell
<Suspense> wrapping dynamic contentFallback in static shell, content streams at request time
cookies(), headers(), searchParamsDynamic — must be inside <Suspense>
Uncached fetch() or DB queriesDynamic — must be inside <Suspense>

Enabling Cache Components

// next.config.ts
import 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>.

Forcing Dynamic Rendering

For the old-style behavior where a route is always dynamic:

// Route segment config (page.tsx or layout.tsx)
export const dynamic = 'force-dynamic' // always server-rendered per request
export const dynamic = 'force-static'  // always static, error if dynamic APIs used
export const revalidate = 60           // ISR — regenerate every 60 seconds
export const revalidate = false        // fully static, never revalidate

Opting Into Dynamic Rendering

When you need request-time data but want to be explicit:

import { connection } from 'next/server'
 
export default async function Page() {
  await connection() // explicitly opt into dynamic rendering
  const id = crypto.randomUUID() // safe — runs at request time
  return <div>Request ID: {id}</div>
}

Streaming

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 fetched
export 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 in
import { 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.


4. Data Fetching

Server Components

Fetch data directly in async Server Components — from databases, ORMs, or HTTP APIs:

// With fetch
export default async function Page() {
  const res = await fetch('https://api.example.com/posts')
  const posts = await res.json()
  return <PostList posts={posts} />
}
 
// With ORM — perfectly safe, runs server-side only
import { db } from '@/lib/db'
 
export default async function Page() {
  const posts = await db.select().from(postsTable)
  return <PostList posts={posts} />
}

Parallel vs Sequential Fetching

// ❌ Sequential — 3 round trips
async function Page() {
  const user = await getUser()           // 100ms
  const posts = await getPosts(user.id)  // 200ms — waits for user
  const comments = await getComments()   // 150ms — waits for posts
  // Total: ~450ms
}
 
// ✅ Parallel — fire simultaneously
async function Page() {
  const [user, posts, comments] = await Promise.all([
    getUser(),
    getPosts(),
    getComments(),
  ])
  // Total: ~200ms (slowest wins)
}
 
// ✅ Sequential when data depends on previous data
async function Page({ params }: { params: Promise<{ userId: string }> }) {
  const { userId } = await params
  const user = await getUser(userId)
  if (!user) notFound()
 
  const posts = await getPostsByUser(user.id) // correct — depends on user
  return <UserProfile user={user} posts={posts} />
}

React cache() for Deduplication

cache() memoizes a function per request. Two components calling getUser('123') in the same render will only hit the database once:

import { cache } from 'react'
 
export const getUser = cache(async (id: string) => {
  return db.users.findById(id)
})
 
// In layout.tsx — hits DB
const user = await getUser(params.id)
 
// In page.tsx (same request) — returns cached result, no DB hit
const user = await getUser(params.id)

This replaces context-based data passing in App Router. Both layout and page call the same cached function — no prop drilling.

Streaming Data with the use API

Pass a Promise from a Server Component to a Client Component and resolve it with use():

// app/blog/page.tsx — Server Component
import Posts from '@/app/ui/posts'
import { Suspense } from 'react'
 
export default function Page() {
  const posts = getPosts() // don't await
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <Posts posts={posts} />
    </Suspense>
  )
}
 
// app/ui/posts.tsx — Client Component
'use client'
import { use } from 'react'
 
export default function Posts({ posts }: { posts: Promise<Post[]> }) {
  const allPosts = use(posts) // resolves the promise, suspends until ready
  return <ul>{allPosts.map(p => <li key={p.id}>{p.title}</li>)}</ul>
}

5. Caching & Revalidation

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.

The use cache Directive

Add 'use cache' to any async function or component to cache its return value:

// Data-level caching
import { cacheLife } from 'next/cache'
 
export async function getProducts() {
  'use cache'
  cacheLife('hours')
  return db.query('SELECT * FROM products')
}
 
// UI-level caching — cache the entire component
export default async function ProductList() {
  'use cache'
  cacheLife('hours')
 
  const products = await db.query('SELECT * FROM products')
  return (
    <ul>
      {products.map(p => <li key={p.id}>{p.name}</li>)}
    </ul>
  )
}
 
// File-level caching — all exports in the file are cached
'use cache'
 
export async function getUser(id: string) {
  cacheLife('days')
  return db.users.findById(id)
}

Arguments and closed-over values become part of the cache key automatically. Different inputs produce separate cache entries.

cacheLife Profiles

import { cacheLife } from 'next/cache'
 
// Built-in profiles
cacheLife('seconds') // stale: 0,   revalidate: 1s,  expire: 60s
cacheLife('minutes') // stale: 5m,  revalidate: 1m,  expire: 1h
cacheLife('hours')   // stale: 5m,  revalidate: 1h,  expire: 1d
cacheLife('days')    // stale: 5m,  revalidate: 1d,  expire: 1w
cacheLife('weeks')   // stale: 5m,  revalidate: 1w,  expire: 30d
cacheLife('max')     // stale: 5m,  revalidate: 30d, expire: ~indefinite
 
// Custom
cacheLife({
  stale: 3600,     // serve stale for up to 1h
  revalidate: 7200, // revalidate after 2h
  expire: 86400,   // expire after 1d
})

Short-lived caches (seconds profile, revalidate: 0, or expire < 5 minutes) are automatically excluded from prerenders and become dynamic holes instead.

cacheTag for On-Demand Invalidation

Tag cached data so you can invalidate it explicitly:

import { cacheTag } from 'next/cache'
 
export async function getProducts() {
  'use cache'
  cacheTag('products')
  cacheLife('hours')
  return db.query('SELECT * FROM products')
}
 
export async function getProduct(id: string) {
  'use cache'
  cacheTag('products', `product-${id}`) // multiple tags
  cacheLife('days')
  return db.products.findById(id)
}

revalidateTag vs updateTag

These look similar but have different semantics:

revalidateTagupdateTag
WhereServer Actions + Route HandlersServer Actions only
BehaviorStale-while-revalidate — serve stale immediately, fresh in backgroundImmediately expires cache — user sees fresh on next request
Use caseBlog posts, product catalogs — slight delay is acceptableUser's own data — read-your-own-writes
import { revalidateTag, updateTag } from 'next/cache'
 
// After admin publishes a blog post — background refresh is fine
export 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 immediately
export async function updateProfile(data: ProfileData) {
  'use server'
  await db.profiles.update(data)
  updateTag('profile') // immediately expires, next request is fresh
  redirect('/profile')
}

revalidatePath

Invalidate all cached data for a specific route path. Prefer tag-based revalidation when possible — it's more precise.

import { revalidatePath } from 'next/cache'
 
export async function createPost() {
  'use server'
  await db.posts.create(...)
  revalidatePath('/posts') // invalidates the /posts route
}

refresh() — Refresh Without Revalidating Tags

After a mutation, refresh the current page's UI without full cache invalidation:

import { refresh } from 'next/cache'
 
export async function updatePost(formData: FormData) {
  'use server'
  await db.posts.update(formData.get('id'), formData.get('title'))
  refresh() // refreshes client router — shows latest state
}

refresh() refreshes the client router. revalidatePath() / revalidateTag() invalidate cached data. Use both together when you need the cache cleared AND the UI refreshed.

Streaming Uncached Data

For components that need fresh data on every request, don't use 'use cache'. Wrap in <Suspense> instead:

import { Suspense } from 'react'
 
async function LiveInventory({ productId }: { productId: string }) {
  // No 'use cache' — fetches fresh on every request
  const inventory = await db.inventory.findByProduct(productId)
  return <p>{inventory.count} in stock</p>
}
 
export default function ProductPage({ productId }: { productId: string }) {
  return (
    <div>
      <CachedProductInfo productId={productId} /> {/* static shell */}
      <Suspense fallback={<p>Checking availability...</p>}>
        <LiveInventory productId={productId} /> {/* streams at request time */}
      </Suspense>
    </div>
  )
}

Runtime APIs Must Be Inside Suspense

cookies(), headers(), searchParams — these are only available at request time. Components accessing them must be inside <Suspense>:

import { cookies } from 'next/headers'
import { Suspense } from 'react'
 
async function UserGreeting() {
  const theme = (await cookies()).get('theme')?.value || 'light'
  return <p>Your theme: {theme}</p>
}
 
export default function Page() {
  return (
    <>
      <h1>Dashboard</h1>
      <Suspense fallback={<p>Loading...</p>}>
        <UserGreeting /> {/* accesses cookies — must be in Suspense */}
      </Suspense>
    </>
  )
}

Passing Runtime Values to Cached Functions

Extract runtime values first, then pass them as arguments to cached functions:

// Component (not cached) reads runtime data
async function ProfileContent() {
  const session = (await cookies()).get('session')?.value
  return <CachedProfile sessionId={session} />
}
 
// Cached component receives value as prop — sessionId becomes cache key
async function CachedProfile({ sessionId }: { sessionId: string }) {
  'use cache'
  cacheLife('minutes')
  const data = await fetchUserData(sessionId)
  return <div>{data.name}</div>
}

The Previous Model (No cacheComponents)

If you're not using Cache Components, the old fetch() semantics still work:

// Cached indefinitely
const data = await fetch(url, { cache: 'force-cache' })
 
// Never cached
const data = await fetch(url, { cache: 'no-store' })
 
// ISR
const data = await fetch(url, { next: { revalidate: 3600 } })
 
// Tagged
const data = await fetch(url, { next: { tags: ['products'] } })

And unstable_cache for non-fetch data sources:

import { unstable_cache } from 'next/cache'
 
export const getCachedUser = unstable_cache(
  async (id: string) => db.users.findById(id),
  ['user'],
  { tags: ['user'], revalidate: 3600 }
)

6. Server Actions

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.

Basic Pattern

'use server'
 
import { redirect } from 'next/navigation'
import { revalidatePath } from 'next/cache'
import { z } from 'zod'
import { auth } from '@/lib/auth'
 
const CreatePostSchema = z.object({
  title: z.string().min(1).max(200),
  content: z.string().min(10),
})
 
export async function createPost(formData: FormData) {
  // 1. Authenticate
  const session = await auth()
  if (!session?.user) throw new Error('Unauthorized')
 
  // 2. Validate
  const result = CreatePostSchema.safeParse({
    title: formData.get('title'),
    content: formData.get('content'),
  })
  if (!result.success) {
    return { error: result.error.flatten().fieldErrors }
  }
 
  // 3. Authorize — check the user can do this specific action
  // (not just that they're logged in)
 
  // 4. Execute
  const post = await db.posts.create({
    data: { ...result.data, authorId: session.user.id },
  })
 
  // 5. Revalidate + redirect
  revalidatePath('/posts')
  redirect(`/posts/${post.id}`)
}

Progressive Enhancement with Forms

Server Actions work without JavaScript — the form submits normally via POST:

// app/posts/new/page.tsx
import { createPost } from './actions'
 
export default function NewPostPage() {
  return (
    <form action={createPost}>
      <input name="title" type="text" required />
      <textarea name="content" required />
      <button type="submit">Create Post</button>
    </form>
  )
}

When JS is enabled, Next.js intercepts the submission and calls the action without a full page reload.

Pending States with useActionState

'use client'
 
import { useActionState } from 'react'
import { createPost } from './actions'
 
export function CreatePostForm() {
  const [state, action, isPending] = useActionState(createPost, null)
 
  return (
    <form action={action}>
      <input name="title" type="text" />
      {state?.error?.title && (
        <p className="text-red-500">{state.error.title[0]}</p>
      )}
      <textarea name="content" />
      {state?.error?.content && (
        <p className="text-red-500">{state.error.content[0]}</p>
      )}
      <button type="submit" disabled={isPending}>
        {isPending ? 'Creating...' : 'Create Post'}
      </button>
    </form>
  )
}
 
// Updated action — returns state on error instead of redirecting
'use server'
 
export async function createPost(prevState: unknown, formData: FormData) {
  const result = CreatePostSchema.safeParse({
    title: formData.get('title'),
    content: formData.get('content'),
  })
  if (!result.success) {
    return { error: result.error.flatten().fieldErrors }
  }
  // ... create post
  revalidatePath('/posts')
  redirect('/posts')
}

Inline Actions

// app/posts/[id]/page.tsx
export default async function PostPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  const post = await getPost(id)
 
  async function deletePost() {
    'use server'
    await db.posts.delete(post.id)
    revalidatePath('/posts')
    redirect('/posts')
  }
 
  return (
    <div>
      <h1>{post.title}</h1>
      <form action={deletePost}>
        <button type="submit">Delete</button>
      </form>
    </div>
  )
}

Security: Always Authorize Resource Ownership

'use server'
 
export async function deleteComment(commentId: string) {
  // ❌ Only checks authentication, not ownership
  const session = await auth()
  if (!session) throw new Error('Unauthorized')
  await db.comments.delete(commentId) // anyone can delete anyone's comment!
}
 
export async function deleteComment(commentId: string) {
  // ✅ Checks ownership too
  const session = await auth()
  if (!session) throw new Error('Unauthorized')
 
  const comment = await db.comments.findById(commentId)
  if (!comment) throw new Error('Not found')
  if (comment.authorId !== session.user.id) throw new Error('Forbidden')
 
  await db.comments.delete(commentId)
  revalidatePath('/comments')
}

Server Actions vs Route Handlers

Server ActionsRoute Handlers
Use forMutations from UIWebhooks, external APIs, non-UI clients
AuthSession-basedToken/signature-based
Progressive enhancementYesNo
RevalidationBuilt-inManual

7. Proxy

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.

The Basics

// 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 export
export default function proxy(request: NextRequest) {
  return NextResponse.next()
}
 
// Only run on specific paths
export const config = {
  matcher: [
    '/dashboard/:path*',
    '/api/:path*',
    // Skip static files and Next.js internals
    '/((?!_next/static|_next/image|favicon.ico).*)',
  ],
}

Auth Pattern

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.ts
import { 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.tsx
import { 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} />
}

Multi-tenant Routing

// proxy.ts
export function proxy(request: NextRequest) {
  const hostname = request.headers.get('host') ?? ''
  const subdomain = hostname.split('.')[0]
 
  if (subdomain === 'www' || subdomain === 'app') {
    return NextResponse.next()
  }
 
  // Rewrite tenant subdomains internally
  const url = request.nextUrl.clone()
  url.pathname = `/tenant/${subdomain}${url.pathname}`
  return NextResponse.rewrite(url)
}
customer.yourapp.com/dashboard
  ↓ proxy rewrites internally
/tenant/customer/dashboard
  ↓ app/tenant/[slug]/dashboard/page.tsx

CSP Nonces

Generate a fresh nonce per request for Content Security Policy:

// proxy.ts
import { NextRequest, NextResponse } from 'next/server'
 
export function proxy(request: NextRequest) {
  const nonce = Buffer.from(crypto.randomUUID()).toString('base64')
  const isDev = process.env.NODE_ENV === 'development'
 
  const csp = `
    default-src 'self';
    script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${isDev ? " 'unsafe-eval'" : ''};
    style-src 'self' 'nonce-${nonce}';
    img-src 'self' blob: data:;
    object-src 'none';
  `.replace(/\s{2,}/g, ' ').trim()
 
  const requestHeaders = new Headers(request.headers)
  requestHeaders.set('x-nonce', nonce)
  requestHeaders.set('Content-Security-Policy', csp)
 
  const response = NextResponse.next({ request: { headers: requestHeaders } })
  response.headers.set('Content-Security-Policy', csp)
  return response
}

Edge Runtime Constraints

Proxy runs on Edge Runtime. You cannot use:

❌ 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.


8. Patterns

Full Auth Flow

// lib/session.ts
import 'server-only'
import { SignJWT, jwtVerify } from 'jose'
import { cookies } from 'next/headers'
import { cache } from 'react'
 
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET!)
 
export async function createSession(userId: string, role: string) {
  const token = await new SignJWT({ sub: userId, role })
    .setProtectedHeader({ alg: 'HS256' })
    .setExpirationTime('7d')
    .setIssuedAt()
    .sign(JWT_SECRET)
 
  const cookieStore = await cookies()
  cookieStore.set('session', token, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: 60 * 60 * 24 * 7,
    path: '/',
  })
}
 
export const getSession = cache(async () => {
  const cookieStore = await cookies()
  const token = cookieStore.get('session')?.value
  if (!token) return null
 
  try {
    const { payload } = await jwtVerify(token, JWT_SECRET)
    return payload as { sub: string; role: string }
  } catch {
    return null
  }
})
 
export async function deleteSession() {
  const cookieStore = await cookies()
  cookieStore.delete('session')
}
// app/actions/auth.ts
'use server'
 
import { z } from 'zod'
import { createSession, deleteSession } from '@/lib/session'
import { redirect } from 'next/navigation'
 
const LoginSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
})
 
export async function login(prevState: unknown, formData: FormData) {
  const result = LoginSchema.safeParse({
    email: formData.get('email'),
    password: formData.get('password'),
  })
  if (!result.success) return { error: 'Invalid email or password' }
 
  const user = await db.users.findByEmail(result.data.email)
  if (!user) return { error: 'Invalid email or password' }
 
  const valid = await verifyPassword(result.data.password, user.passwordHash)
  if (!valid) return { error: 'Invalid email or password' }
 
  await createSession(user.id, user.role)
  redirect('/dashboard')
}
 
export async function logout() {
  await deleteSession()
  redirect('/login')
}

Optimistic UI with useOptimistic

'use client'
 
import { useOptimistic, useTransition } from 'react'
import { toggleLike } from './actions'
 
export function LikeButton({ post }: { post: { id: string; likes: number; likedByUser: boolean } }) {
  const [optimisticPost, addOptimisticUpdate] = useOptimistic(
    post,
    (state, liked: boolean) => ({
      ...state,
      likes: liked ? state.likes + 1 : state.likes - 1,
      likedByUser: liked,
    })
  )
  const [isPending, startTransition] = useTransition()
 
  const handleToggle = () => {
    const willBeLiked = !optimisticPost.likedByUser
    startTransition(async () => {
      addOptimisticUpdate(willBeLiked) // instant UI update
      await toggleLike(post.id)        // actual server call
    })
  }
 
  return (
    <button onClick={handleToggle} disabled={isPending}>
      {optimisticPost.likedByUser ? '❤️' : '🤍'} {optimisticPost.likes}
    </button>
  )
}

URL State Management

Store UI state in URL search params — shareable, survives refresh:

// hooks/useQueryState.ts
'use client'
 
import { useRouter, useSearchParams, usePathname } from 'next/navigation'
import { useCallback, useTransition } from 'react'
 
export function useQueryState(key: string, defaultValue = '') {
  const router = useRouter()
  const pathname = usePathname()
  const searchParams = useSearchParams()
  const [isPending, startTransition] = useTransition()
 
  const value = searchParams.get(key) ?? defaultValue
 
  const setValue = useCallback((newValue: string) => {
    const params = new URLSearchParams(searchParams.toString())
    if (newValue === defaultValue) {
      params.delete(key)
    } else {
      params.set(key, newValue)
    }
    startTransition(() => {
      router.replace(`${pathname}?${params.toString()}`, { scroll: false })
    })
  }, [key, defaultValue, pathname, router, searchParams])
 
  return [value, setValue, isPending] as const
}

Multi-tenant Architecture

// app/tenant/[slug]/layout.tsx
import { notFound } from 'next/navigation'
import { getTenant } from '@/lib/tenant'
 
export default async function TenantLayout({
  children,
  params,
}: {
  children: React.ReactNode
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  const tenant = await getTenant(slug)
  if (!tenant) notFound()
 
  return (
    <TenantProvider tenant={tenant}>
      <TenantTheme primaryColor={tenant.brandColor}>
        {children}
      </TenantTheme>
    </TenantProvider>
  )
}

Instant Navigation with unstable_instant

Next.js 16 introduces unstable_instant to validate that navigating to a route is instant (no blocking uncached data outside Suspense boundaries):

// app/store/[slug]/page.tsx
import { Suspense } from 'react'
 
export const unstable_instant = {
  prefetch: 'static',
  samples: [{ params: { slug: 'example-product' } }],
}
 
export default function ProductPage(props: PageProps<'/store/[slug]'>) {
  return (
    <div>
      <Suspense fallback={<ProductSkeleton />}>
        <ProductInfo params={props.params} />
      </Suspense>
      <Suspense fallback={<InventorySkeleton />}>
        <Inventory params={props.params} />
      </Suspense>
    </div>
  )
}
 
async function ProductInfo({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params
  const product = await getCachedProduct(slug)
  return <h1>{product.name}</h1>
}
 
async function getCachedProduct(slug: string) {
  'use cache'
  cacheLife('hours')
  return db.products.findBySlug(slug)
}
 
async function Inventory({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params
  const item = await db.inventory.findBySlug(slug) // no cache — fresh every request
  return <p>{item.count} in stock</p>
}

During development, Next.js validates the Suspense boundary structure. During build, samples provides example params to simulate navigations.

Error Boundaries

// app/dashboard/error.tsx
'use client'
 
import { useEffect } from 'react'
 
export default function Error({
  error,
  unstable_retry,
}: {
  error: Error & { digest?: string }
  unstable_retry: () => void // was `reset` in older versions
}) {
  useEffect(() => {
    console.error(error)
  }, [error])
 
  return (
    <div>
      <h2>Something went wrong</h2>
      <button onClick={unstable_retry}>Try again</button>
    </div>
  )
}
// app/global-error.tsx — catches errors in root layout
'use client'
 
export default function GlobalError({
  error,
  unstable_retry,
}: {
  error: Error & { digest?: string }
  unstable_retry: () => void
}) {
  return (
    <html>
      <body>
        <h2>Critical error</h2>
        <button onClick={unstable_retry}>Try again</button>
      </body>
    </html>
  )
}

9. Production Checklist

Performance

// ✅ Images — always use next/image
import Image from 'next/image'
<Image src="/hero.jpg" width={1200} height={630} alt="Hero" priority />
 
// ✅ Fonts — use next/font, zero layout shift
import { Geist } from 'next/font/google'
const geist = Geist({ subsets: ['latin'] })
 
// ✅ Scripts — use next/script for third-party
import Script from 'next/script'
<Script src="https://analytics.example.com" strategy="lazyOnload" />
 
// ✅ Dynamic imports for heavy components
import dynamic from 'next/dynamic'
const HeavyChart = dynamic(() => import('./HeavyChart'), {
  loading: () => <ChartSkeleton />,
  ssr: false, // if it uses browser APIs
})

Security Headers

// next.config.ts
const headers = async () => [
  {
    source: '/(.*)',
    headers: [
      { key: 'X-Frame-Options', value: 'DENY' },
      { key: 'X-Content-Type-Options', value: 'nosniff' },
      { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
      {
        key: 'Strict-Transport-Security',
        value: 'max-age=31536000; includeSubDomains',
      },
    ],
  },
]

Environment Variables

// lib/env.ts — validate at startup, fail fast
import { z } from 'zod'
 
const envSchema = z.object({
  DATABASE_URL: z.string().url(),
  JWT_SECRET: z.string().min(32),
  NODE_ENV: z.enum(['development', 'test', 'production']),
  NEXT_PUBLIC_APP_URL: z.string().url(),
})
 
export const env = envSchema.parse(process.env)

Metadata & SEO

// app/layout.tsx
import type { Metadata } from 'next'
 
export const metadata: Metadata = {
  metadataBase: new URL('https://yourapp.com'),
  title: { default: 'Your App', template: '%s | Your App' },
  description: 'Default description',
  openGraph: { type: 'website', locale: 'en_US', siteName: 'Your App' },
  robots: { index: true, follow: true },
}
 
// app/blog/[slug]/page.tsx — dynamic metadata
export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params
  const post = await getPost(slug)
  if (!post) return {}
 
  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      images: [{ url: `/api/og?title=${encodeURIComponent(post.title)}` }],
    },
  }
}

Dynamic OG Images

// app/api/og/route.tsx
import { ImageResponse } from 'next/og'
 
export const runtime = 'edge'
 
export async function GET(request: Request) {
  const { searchParams } = new URL(request.url)
  const title = searchParams.get('title') ?? 'Default Title'
 
  return new ImageResponse(
    (
      <div style={{
        display: 'flex',
        width: '100%',
        height: '100%',
        background: '#0f172a',
        alignItems: 'center',
        justifyContent: 'center',
      }}>
        <h1 style={{ color: 'white', fontSize: 64 }}>{title}</h1>
      </div>
    ),
    { width: 1200, height: 630 }
  )
}

Pre-deploy Checklist

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

Appendix: Quick Reference

File Conventions

app/
├── layout.tsx          Persistent wrapper (no remount on nav)
├── template.tsx        Fresh wrapper (remounts on nav)
├── page.tsx            Route UI
├── loading.tsx         Suspense fallback for page
├── error.tsx           Error boundary ('use client' required)
├── not-found.tsx       404 UI
├── route.ts            API handler (GET, POST, etc.)
├── proxy.ts            Edge proxy (root or /src only) — was middleware.ts
└── [folder]/
    ├── (group)/        Route group — no URL segment
    ├── [param]/        Dynamic segment
    ├── [...rest]/      Catch-all (one or more)
    ├── [[...rest]]/    Optional catch-all (zero or more)
    ├── @slot/          Parallel route slot
    └── (.)intercept/   Intercepting route

Caching API Reference

// use cache directive
'use cache'                        // in function or component body
cacheLife('hours')                 // set cache lifetime
cacheTag('products', 'featured')   // tag for invalidation
 
// Invalidation
revalidateTag('tag')               // stale-while-revalidate (Server Action or Route Handler)
updateTag('tag')                   // immediate expire (Server Action only)
revalidatePath('/path')            // invalidate by path
refresh()                          // refresh client router
 
// Route segment config (page.tsx / layout.tsx)
export const dynamic = 'force-dynamic' | 'force-static' | 'auto'
export const revalidate = 60       // ISR in seconds
export const revalidate = false    // static, never revalidate
 
// Previous model (without cacheComponents)
fetch(url, { cache: 'force-cache' })
fetch(url, { cache: 'no-store' })
fetch(url, { next: { revalidate: 60 } })
fetch(url, { next: { tags: ['tag'] } })
unstable_cache(fn, keyParts, { tags, revalidate })

Rendering Decision

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.