Next.js Best Practices
The App Router gives you a lot of power — Server Components, streaming, Server Actions, fine-grained caching — but that power comes with defaults you need to lean into deliberately rather than fight. The checklist below covers the habits that consistently separate a fast, maintainable App Router codebase from one that quietly reproduces every downside of a client-only SPA.
The checklist
Default to Server Components. Every component under
app/is a Server Component unless it opts out with'use client'. Write with that as the baseline instead of reaching for'use client'out of habit.Push
'use client'to the leaves. Mark the smallest possible component — the button, the input, the interactive widget — as a Client Component, and pass Server Component output in aschildren, rather than marking an entire page or layout client-side.Use the built-in optimization primitives.
next/imagefor automatic resizing/lazy-loading,next/fontfor zero-layout-shift self-hosted fonts, andnext/scriptfor controlling third-party script loading strategy — all three replace hand-rolled equivalents that are easy to get subtly wrong.Prefer Server Actions for mutations that belong to your own UI. Skip hand-writing a Route Handler plus a matching
fetchcall for a form your own app owns; reserve Route Handlers for things that need a real URL — webhooks, third-party integrations, or endpoints consumed outside your app.Use
generateMetadatafor anything dynamic. Staticmetadataexports cover fixed titles/descriptions; the moment SEO data depends on fetched content (a blog post title, a product name), switch to the asyncgenerateMetadatafunction.Lean on static rendering and ISR wherever content allows it. A page that does not need per-request personalization should be static or incrementally revalidated (
revalidate), not force-dynamic — static pages are dramatically cheaper and faster to serve.Add
loading.tsxand Suspense boundaries around slow data. Streaming in a loading state for a slow section keeps the rest of the page interactive instead of blocking the entire route on the slowest fetch.Always validate and authorize inside Server Actions, even though they look like local functions. A Server Action is a public network endpoint — treat every argument as untrusted input and re-check the caller's permissions on the server, the same as you would for an API route.
Keep Middleware lightweight. Middleware runs on every matching request before a page even starts rendering — limit it to fast checks (auth redirects, header rewrites, A/B routing) and avoid slow database calls or heavy computation there.
Measure with Core Web Vitals, not intuition. Use
next/font's andnext/image's built-in wins as a starting point, then verify real-world impact with Lighthouse, Vercel Analytics, or the Chrome User Experience Report rather than assuming a change helped.
A Server Action that validates before it trusts
app/actions/updateProfile.ts
'use server'
import { auth } from '@/lib/auth'
import { z } from 'zod'
const schema = z.object({
displayName: z.string().min(1).max(80),
})
export async function updateProfile(formData: FormData) {
// Never trust that the caller is who the client claims — re-check on
// the server, every time, even though this "looks like" a local call.
const session = await auth()
if (!session) {
throw new Error('Not authenticated')
}
const parsed = schema.safeParse({
displayName: formData.get('displayName'),
})
if (!parsed.success) {
throw new Error('Invalid input')
}
await db.user.update({
where: { id: session.userId },
data: { displayName: parsed.data.displayName },
})
}Server Components by default,
'use client'only where interactivity is actually needed.Built-in
next/image,next/font, andnext/scriptinstead of hand-rolled equivalents.Static rendering and ISR wherever content allows it, reserving fully dynamic rendering for pages that truly need it.
Authorization and validation happen on the server inside every Server Action, never assumed from the client.