Next.js Best Practices
Next.js 16.2.3 App Router Best Practices
Next.js 16.2.3 is no longer just a convenient way to bootstrap a React app. In production, it acts as a full-stack web framework with strong opinions about rendering, data access, caching, mutation flows, SEO, and operational behavior.
The hard part is not getting a page to render. The hard part is building a system that remains understandable, performant, secure, and maintainable when:
- multiple engineers work on it every day,
- requirements evolve quickly,
- data freshness and performance must be balanced,
- server and client boundaries must stay intentional,
- failures must be diagnosable after deployment.
This guide is not a beginner tutorial. It is a production-oriented set of Next.js 16.2.3 App Router practices for teams building real systems. It focuses on Server Components, caching, Server Actions, performance, security, and engineering conventions.
Core principles
Before discussing implementation details, align on a few architectural principles. Most long-term problems in Next.js projects come from teams being inconsistent about these fundamentals.
1. Default to Server First
With the App Router, Server Components are not a niche feature. They should be the default model.
Use the server first for:
- page composition,
- layout composition,
- data fetching,
- authorization checks,
- metadata and SEO generation,
- access to databases, internal services, and secrets.
This approach gives you:
- less client-side JavaScript,
- better initial performance,
- natural access to backend capabilities,
- more explicit ownership of data and rendering.
Use Client Components only when you truly need:
- browser event handlers,
- local interactive state,
useState,useReducer,useEffect,- browser-only APIs,
- client-only third-party UI libraries.
2. Boundaries matter more than convenience
Many teams gradually erode architecture in the name of “reuse” or “speed”:
- client code imports server modules,
- database entities are passed directly into UI,
- every page decides its own cache behavior,
- permission logic lives only in the frontend,
- data fetching happens anywhere.
In production, clear boundaries matter more than short-term convenience. Reuse is valuable, but boundaries are what keep a system coherent over time.
3. Caching is an architectural concern
In Next.js 15, caching is not a late-stage performance patch. It directly affects:
- perceived freshness,
- page behavior,
- mutation consistency,
- deployment reliability,
- operational stability under load.
That means caching strategy should be defined deliberately at the module level, not ad hoc in page files.
4. Security and authorization must execute on the server
Hiding a button is not authorization. Real authorization must happen in server-executed code:
- Server Components that read sensitive data,
- Route Handlers,
- Server Actions,
- service and policy layers.
The UI is an experience boundary, not a security boundary.
5. Team conventions beat personal style
Next.js is flexible enough that a project can easily end up with:
- three ways to fetch data,
- four styles of cache configuration,
- multiple route organization patterns,
- unclear mutation boundaries.
The project may still work, but operational and maintenance costs will rise quickly. Strong team conventions prevent that drift.
Installation and versioning
Runtime and dependency baseline
For production projects, standardize the baseline early:
- Node.js 20.9+,
- Next.js 16.2.3,
- React 19,
- TypeScript strict mode,
- ESLint and consistent formatting,
- one package manager for the entire repo,
- lockfile-based installs in CI.
A typical project bootstrap:
pnpm create next-app@latest my-app
cd my-app
pnpm devRecommended defaults for new projects:
- TypeScript
- App Router
- ESLint
src/directory- Tailwind CSS if your team uses it
- import aliases
Versioning strategy
Do not treat Next.js upgrades as something to postpone indefinitely. A better approach is:
- validate major upgrades deliberately,
- follow minor and patch releases on a regular cadence,
- read release notes and breaking changes before upgrading,
- verify behavior in staging, especially around:
- fetch caching semantics,
- static vs dynamic rendering,
- middleware behavior,
- Server Actions,
- runtime compatibility.
Engineering recommendations
- Pin the Node version in the repo and CI.
- Pin the package manager version.
- Use Renovate or Dependabot for dependency maintenance.
- Before adopting a Next.js upgrade, explicitly test:
- cache behavior,
- route rendering mode,
- route handler runtime behavior,
- image and font optimization,
- build output mode.
App Router vs Pages Router
Why App Router should be the default for new projects
For new builds, App Router is the preferred model because it aligns better with modern full-stack React architecture:
- Server Components by default,
- nested layouts,
- streaming and Suspense-first rendering,
- co-located route concerns,
- integrated loading, error, and not-found behavior,
- better support for revalidation and Server Actions.
When Pages Router still makes sense
If you have a mature Pages Router application that is already stable, migration should be intentional rather than ideological. Incremental migration is often the right choice when:
- the system has a large legacy surface area,
- old ecosystem dependencies remain critical,
- short-term migration ROI is low,
- the team is not yet ready to operate App Router well.
Practical recommendation
- New project: use App Router.
- Existing project: migrate gradually by domain or route group.
- Avoid big-bang rewrites unless the current architecture is actively blocking delivery.
Recommended project structure
There is no single perfect folder structure, but production-grade projects should reflect responsibility boundaries clearly.
src/
├── app/
│ ├── (marketing)/
│ │ ├── layout.tsx
│ │ ├── page.tsx
│ │ └── pricing/page.tsx
│ ├── (app)/
│ │ ├── dashboard/page.tsx
│ │ ├── settings/page.tsx
│ │ └── users/[id]/page.tsx
│ ├── api/
│ │ └── reports/route.ts
│ ├── globals.css
│ ├── layout.tsx
│ ├── error.tsx
│ ├── not-found.tsx
│ └── sitemap.ts
├── components/
│ ├── ui/
│ ├── shared/
│ └── feature/
├── features/
│ ├── auth/
│ │ ├── actions.ts
│ │ ├── queries.ts
│ │ ├── dto.ts
│ │ ├── schema.ts
│ │ └── components/
│ ├── users/
│ └── billing/
├── lib/
│ ├── db/
│ ├── auth/
│ ├── cache/
│ ├── env/
│ ├── observability/
│ └── utils/
├── server/
│ ├── services/
│ ├── repositories/
│ └── policies/
├── styles/
├── tests/
├── types/
└── middleware.tsStructural guidance
app/
Keep route concerns here:
page.tsxlayout.tsxloading.tsxerror.tsxnot-found.tsxroute.ts- metadata files and route-level composition
Do not turn app/ into a dumping ground for business logic.
features/
Organize by business domain rather than by technical file type. Each domain can contain:
- read queries,
- write actions,
- DTOs,
- schemas,
- domain-specific components.
server/
Place clearly server-only implementation here:
- repositories,
- services,
- policies,
- authorization logic,
- audit logic,
- backend integrations.
components/
Organize reusable UI by layer:
ui/for low-level design system primitives,shared/for cross-domain composed components,feature/for reusable presentation components tied to specific domain concepts.
Server First and client boundaries
Start with a Server Component
A common anti-pattern is writing entire pages as Client Components because the team is used to traditional React.
Instead, begin with a Server Component:
// app/dashboard/page.tsx
import { getDashboardSummary } from '@/features/dashboard/queries'
export default async function DashboardPage() {
const summary = await getDashboardSummary()
return (
<main>
<h1>Dashboard</h1>
<p>MRR: {summary.mrr}</p>
<p>Active users: {summary.activeUsers}</p>
</main>
)
}Push interaction to the smallest possible island
If only one part of the page needs interactivity, isolate that part rather than marking the whole page with 'use client'.
// app/dashboard/page.tsx
import { getDashboardSummary } from '@/features/dashboard/queries'
import { DateRangePicker } from '@/features/dashboard/components/date-range-picker'
export default async function DashboardPage() {
const summary = await getDashboardSummary()
return (
<main>
<h1>Dashboard</h1>
<DateRangePicker />
<p>MRR: {summary.mrr}</p>
</main>
)
}// features/dashboard/components/date-range-picker.tsx
'use client'
import { useState } from 'react'
export function DateRangePicker() {
const [range, setRange] = useState('30d')
return (
<div>
<button onClick={() => setRange('7d')}>7d</button>
<button onClick={() => setRange('30d')}>30d</button>
<span>{range}</span>
</div>
)
}Practical boundary rules
Client Components should mostly handle:
- interactions,
- browser state,
- local feedback,
- browser-only APIs.
Server Components should mostly handle:
- data access,
- data shaping,
- authorization,
- SEO,
- page assembly.
Cases that do not justify a Client Component
These are often misclassified:
- rendering a list,
- fetching data for initial page load,
- reading from a database,
- conditional UI based on session or cookies,
- URL-driven filtering for first render.
Those are usually server concerns.
server-only and DTOs
Why server-only matters
One of the easiest ways for boundaries to decay is accidental cross-importing. A server module gets imported into client code, and suddenly you have:
- build-time failures,
- blurred architecture,
- accidental reliance on server-only behavior,
- risk of exposing private implementation.
Use server-only in modules that must never be imported into the client:
// server/services/user-service.ts
import 'server-only'
import { db } from '@/lib/db'
export async function getInternalUserRecord(userId: string) {
return db.user.findUnique({
where: { id: userId },
})
}Do not pass database entities directly into UI
In production systems, DTOs are strongly recommended for cross-layer data flow. Passing ORM entities straight into components creates multiple problems:
- too many fields,
- unstable UI coupling to database shape,
- accidental exposure of sensitive attributes,
- difficult refactors when the schema changes.
Recommended approach:
// features/users/dto.ts
export type UserProfileDto = {
id: string
displayName: string
avatarUrl: string | null
role: 'admin' | 'member'
}// features/users/queries.ts
import 'server-only'
import { db } from '@/lib/db'
import type { UserProfileDto } from './dto'
export async function getUserProfileDto(userId: string): Promise<UserProfileDto | null> {
const user = await db.user.findUnique({
where: { id: userId },
select: {
id: true,
name: true,
avatarUrl: true,
role: true,
},
})
if (!user) return null
return {
id: user.id,
displayName: user.name,
avatarUrl: user.avatarUrl,
role: user.role,
}
}DTO guidance
- UI consumes DTOs, not raw database entities.
- DTOs contain only the fields required by the consumer.
- Sensitive fields are excluded by default.
- Use explicit names such as:
UserListItemDtoUserProfileDtoInvoiceSummaryDto
DTO discipline improves maintainability and reduces accidental data exposure.
Data fetching and caching
In App Router applications, data fetching and caching must be treated as first-class design decisions.
Fetch data on the server by default
// features/reports/queries.ts
import 'server-only'
export async function getReports() {
const response = await fetch('https://api.example.com/reports', {
next: { tags: ['reports'], revalidate: 300 },
})
if (!response.ok) {
throw new Error('Failed to load reports')
}
return response.json()
}// app/(app)/reports/page.tsx
import { getReports } from '@/features/reports/queries'
export default async function ReportsPage() {
const reports = await getReports()
return (
<main>
<h1>Reports</h1>
<ul>
{reports.map((report: { id: string; title: string }) => (
<li key={report.id}>{report.title}</li>
))}
</ul>
</main>
)
}Three fundamental cache modes
1. force-cache for stable content
await fetch('https://api.example.com/navigation', {
cache: 'force-cache',
})Good for:
- navigation data,
- infrequently changing CMS content,
- shared configuration,
- public static-like data.
2. revalidate for controlled freshness
await fetch('https://api.example.com/posts', {
next: { revalidate: 300 },
})Good for:
- article or product lists,
- dashboards with moderate freshness needs,
- public pages that can tolerate slight staleness.
3. no-store for strongly fresh data
await fetch('https://api.example.com/account', {
cache: 'no-store',
})Good for:
- user-private data,
- transaction status,
- billing state,
- permission-sensitive reads,
- content that must be current on every request.
Centralize cache strategy in query modules
Avoid scattering cache choices across page files. Prefer module-level queries that own their own caching behavior:
// features/posts/queries.ts
import 'server-only'
export async function getPublishedPosts() {
return fetch('https://api.example.com/posts', {
next: { tags: ['posts'], revalidate: 600 },
}).then((res) => {
if (!res.ok) throw new Error('Failed to fetch posts')
return res.json()
})
}Pages should ask for data, not decide caching policy every time.
Practical cache design rules
- Public content is often cacheable.
- User-private data is usually
no-store. - Use tags and path revalidation for mutation consistency.
- Do not let the same resource type have multiple cache semantics across the codebase.
- Be explicit about which pages may serve slightly stale content.
revalidateTag, updateTag, and revalidatePath
In production systems, cache invalidation should be explicit and close to the mutation that changes data.
When to use revalidateTag
Use tags when multiple routes depend on the same category of data:
- post lists,
- product catalogs,
- report summaries,
- shared navigation or taxonomy data.
// features/posts/actions.ts
'use server'
import { revalidateTag } from 'next/cache'
export async function createPost(formData: FormData) {
const title = formData.get('title')
if (typeof title !== 'string' || !title.trim()) {
throw new Error('Invalid title')
}
// persist data
// await db.post.create(...)
revalidateTag('posts')
}Matching read side:
await fetch('https://api.example.com/posts', {
next: { tags: ['posts'], revalidate: 600 },
})When to use revalidatePath
Use path revalidation when a specific page or route subtree should refresh immediately:
- a user updates their profile page,
- a detail page changed,
- a form submission should refresh the current route,
- a dashboard section needs a route-level refresh.
'use server'
import { revalidatePath } from 'next/cache'
export async function updateProfile(formData: FormData) {
const userId = formData.get('userId')
// update data...
revalidatePath('/settings/profile')
if (typeof userId === 'string') {
revalidatePath(`/users/${userId}`)
}
}Recommendations
- Use
revalidateTagfor shared data categories. - Use
revalidatePathfor targeted route refreshes. - Combining both is valid when justified.
- Avoid broad, careless invalidation.
- Define cache tag constants in one place.
export const CACHE_TAGS = {
posts: 'posts',
reports: 'reports',
users: 'users',
} as constAvoiding waterfalls with Promise.all and Suspense
What a waterfall looks like
A waterfall happens when independent requests are awaited sequentially:
export default async function DashboardPage() {
const account = await getAccount()
const invoices = await getInvoices()
const usage = await getUsage()
return <Dashboard account={account} invoices={invoices} usage={usage} />
}If those requests do not depend on one another, this pattern adds unnecessary latency.
Parallelize with Promise.all
export default async function DashboardPage() {
const [account, invoices, usage] = await Promise.all([
getAccount(),
getInvoices(),
getUsage(),
])
return <Dashboard account={account} invoices={invoices} usage={usage} />
}Stream with Suspense
When different sections have different response times, avoid blocking the entire page. Break slow sections into async Server Components and render them with Suspense:
// app/(app)/dashboard/page.tsx
import { Suspense } from 'react'
import { RevenueCard } from '@/features/dashboard/components/revenue-card'
import { ActivityFeed } from '@/features/dashboard/components/activity-feed'
import {
RevenueCardSkeleton,
ActivityFeedSkeleton,
} from '@/features/dashboard/components/skeletons'
export default function DashboardPage() {
return (
<main>
<h1>Dashboard</h1>
<Suspense fallback={<RevenueCardSkeleton />}>
<RevenueCard />
</Suspense>
<Suspense fallback={<ActivityFeedSkeleton />}>
<ActivityFeed />
</Suspense>
</main>
)
}// features/dashboard/components/revenue-card.tsx
import { getRevenue } from '@/features/dashboard/queries'
export async function RevenueCard() {
const revenue = await getRevenue()
return <section>Revenue: {revenue.total}</section>
}Practical recommendations
- Fetch independent data in parallel.
- Use Suspense to isolate slower sections.
- Keep fallbacks close to final layout to avoid layout shift.
- Avoid wrapping the whole page in one giant Suspense boundary.
- Only sequence awaits when there is a real dependency.
Route Handlers vs Server Actions
Both execute on the server, but they serve different architectural roles.
When to use Route Handlers
Route Handlers are the right choice for HTTP interfaces:
- external APIs,
- webhooks,
- file upload endpoints,
- third-party callbacks,
- endpoints called by non-React consumers,
- cases where request and response semantics must be explicit.
// app/api/reports/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { getReportsForApi } from '@/server/services/report-service'
export async function GET(request: NextRequest) {
const reports = await getReportsForApi(request)
return NextResponse.json({ data: reports })
}When to use Server Actions
Server Actions are ideal for UI-driven mutations tightly coupled to the current React tree:
- forms,
- create/update/delete operations,
- in-app mutations,
- mutation flows that should immediately revalidate or redirect.
// features/projects/actions.ts
'use server'
import { redirect } from 'next/navigation'
import { revalidateTag } from 'next/cache'
export async function createProject(formData: FormData) {
const name = formData.get('name')
if (typeof name !== 'string' || !name.trim()) {
throw new Error('Project name is required')
}
// await db.project.create(...)
revalidateTag('projects')
redirect('/projects')
}Used from a component:
export function CreateProjectForm() {
return (
<form action={createProject}>
<input name="name" placeholder="Project name" />
<button type="submit">Create</button>
</form>
)
}Selection guidelines
Prefer Route Handlers when
- you need a standard HTTP API,
- mobile apps or external systems will call it,
- protocol boundaries must stay explicit,
- request and response semantics are a first-class concern.
Prefer Server Actions when
- the mutation originates from the current React UI,
- a form is tightly coupled to a server mutation,
- you want less boilerplate for in-app write flows,
- revalidation and redirects are part of the same unit of work.
Important notes
- Do not mechanically convert every mutation into a Server Action.
- Do not expose public integrations through Server Actions.
- Always validate input and authorize mutations inside Server Actions.
- Both Route Handlers and Server Actions should call shared services instead of duplicating business rules.
Metadata and SEO
The Metadata API should be standard practice in App Router projects rather than something improvised per page.
Base metadata
// app/layout.tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
metadataBase: new URL('https://example.com'),
title: {
default: 'Acme',
template: '%s | Acme',
},
description: 'Production SaaS platform built with Next.js 16.2.3',
}Dynamic metadata
For detail pages, generate metadata from server-fetched data:
// app/blog/[slug]/page.tsx
import type { Metadata } from 'next'
import { getPostBySlug } from '@/features/posts/queries'
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>
}): Promise<Metadata> {
const { slug } = await params
const post = await getPostBySlug(slug)
if (!post) {
return {
title: 'Post Not Found',
}
}
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
type: 'article',
},
}
}SEO recommendations
- Every indexable page should have a clear title and description.
- Generate canonical URLs, Open Graph, and Twitter metadata consistently.
- Use built-in support for sitemap and robots where appropriate.
- Add structured data for relevant business scenarios.
- Do not bury SEO logic inside Client Components.
- Critical content should be renderable on the server, not only after client fetches.
Performance optimization
Performance should be designed into the system, not retrofitted at the end.
1. Reduce client-side JavaScript first
The highest-leverage optimization is often not memoization, but sending less code to the browser at all.
That means:
- defaulting to Server Components,
- shrinking
'use client'boundaries, - avoiding large shared client-side dependencies,
- dynamically importing heavy interactive modules.
2. Use next/image
import Image from 'next/image'
export function Avatar() {
return (
<Image
src="/avatars/jane.png"
alt="Jane"
width={80}
height={80}
sizes="80px"
/>
)
}Guidance:
- always provide
alt, - declare appropriate dimensions,
- use
priorityonly for critical above-the-fold images, - set
sizesin responsive contexts.
3. Use next/font
import { Inter } from 'next/font/google'
const inter = Inter({
subsets: ['latin'],
display: 'swap',
})This avoids unnecessary external font loading overhead and reduces layout instability.
4. Dynamically import heavy client modules
'use client'
import dynamic from 'next/dynamic'
const Chart = dynamic(() => import('./chart'), {
loading: () => <p>Loading chart...</p>,
})Appropriate for:
- charting libraries,
- rich text editors,
- maps,
- large visualizations.
5. Reduce duplicate data access
- consolidate reads in query modules,
- avoid refetching on the client when the server already has the data,
- understand fetch deduplication, but do not rely on accidental behavior to cover poor design.
6. Control streaming UX carefully
- use realistic loading skeletons,
- minimize layout shift between fallback and final content,
- prioritize critical sections of the page.
7. Track real performance signals
At minimum, monitor:
- LCP,
- INP,
- CLS,
- TTFB,
- hydration cost,
- server request duration,
- cache hit rate.
Styling and component layers
There is no universally correct styling solution, but the styling approach should align with your component layering strategy.
Recommended component layers
1. UI foundation layer
Examples: Button, Input, Dialog, Badge.
Properties:
- low business coupling,
- stable APIs,
- composable behavior,
- accessibility built in.
2. Shared composition layer
Examples: PageHeader, EmptyState, DataTableToolbar.
Properties:
- reusable across business areas,
- not tightly bound to database entities,
- accepts explicit props or DTOs.
3. Feature layer
Examples: BillingSummaryCard, UserRoleEditor.
Properties:
- closely aligned with domain language,
- allowed to depend on business concepts,
- not forced into artificial cross-domain reuse.
Styling recommendations
Common choices include:
- Tailwind CSS for fast, systematic UI work,
- CSS Modules for local style isolation,
- CSS Variables for theming,
- small helpers like
clsxorcvafor variants.
Regardless of the choice, standardize:
- design tokens,
- spacing scale,
- color system,
- radii and elevation,
- responsive breakpoints,
- state naming conventions,
- dark mode strategy.
Problems to avoid
- very long inline utility chains with no abstraction,
- duplicating UI styles in feature components,
- inconsistent semantic behavior for the same visual component,
- overloading Client Components with presentational logic.
Security and authorization
One of the biggest risks in Next.js teams is assuming that a React-based app is “mostly frontend” and therefore security primarily belongs elsewhere. In App Router, much more logic moves onto the server, so server-side security discipline becomes even more important.
Core rules
- validate all input,
- authorize all writes,
- apply access control to sensitive reads,
- minimize the data returned to clients,
- keep secrets server-side,
- never trust role, user ID, or tenant ID coming from the browser.
Input validation
Whether using Route Handlers or Server Actions, validate input with an explicit schema.
// features/users/schema.ts
import { z } from 'zod'
export const updateUserSchema = z.object({
name: z.string().min(1).max(80),
role: z.enum(['admin', 'member']),
})'use server'
import { updateUserSchema } from './schema'
export async function updateUserAction(formData: FormData) {
const parsed = updateUserSchema.safeParse({
name: formData.get('name'),
role: formData.get('role'),
})
if (!parsed.success) {
throw new Error('Invalid input')
}
// authorize + update
}Authorization must live on the server
import 'server-only'
export async function deleteProject(projectId: string, currentUserId: string) {
const membership = await getProjectMembership(projectId, currentUserId)
if (!membership || membership.role !== 'admin') {
throw new Error('Forbidden')
}
// perform delete
}Minimize sensitive output
Do not expose the following to the client unless absolutely necessary:
- password hashes,
- access or refresh tokens,
- internal audit fields,
- raw private vendor payloads,
- internal risk labels.
CSRF, XSS, and safe output
- rely on framework escaping by default,
- sanitize rich text carefully,
- align authenticated mutations with a sound cookie strategy,
- avoid careless
dangerouslySetInnerHTML, - validate uploaded content by type and size.
Middleware boundaries
In Next.js 16.2.3, proxy.ts is the preferred network boundary entry point. middleware.ts may still exist in older codebases or specific Edge scenarios, but it should be treated as a transition path.
Good use cases for middleware
- lightweight auth gating,
- redirects,
- locale routing,
- experiment routing,
- URL normalization,
- lightweight request preprocessing.
Bad use cases for middleware
- heavy database queries,
- complex business authorization,
- long-running logic,
- broad third-party API orchestration,
- decisions that require full business context.
Principle
Middleware should remain light, fast, and predictable. It is an entry boundary, not your business orchestration layer.
// proxy.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function proxy(request: NextRequest) {
const session = request.cookies.get('session')
if (!session && request.nextUrl.pathname.startsWith('/app')) {
return NextResponse.redirect(new URL('/login', request.url))
}
return NextResponse.next()
}
export const config = {
matcher: ['/app/:path*'],
}Recommendations
- use middleware for coarse gating,
- keep real authorization in Server Components, Actions, Route Handlers, and services,
- scope matchers carefully,
- remember runtime constraints, especially when middleware runs at the edge.
Error, loading, and not-found behavior
The App Router file conventions for error and loading states are part of the product experience, not optional extras.
loading.tsx
Use route-level loading UI to provide a meaningful skeleton. It should:
- resemble the final layout,
- communicate progress structurally,
- avoid generic “Loading…” text where possible.
error.tsx
Use error boundaries to contain failures and preserve as much page functionality as possible.
'use client'
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<main>
<h2>Failed to load this page</h2>
<p>{error.message}</p>
<button onClick={() => reset()}>Try again</button>
</main>
)
}not-found.tsx
Use this for missing resources so users get a clear, intentional state instead of blank UI.
Practical recommendations
- use
notFound()for true absence, not for authorization failures, - treat authorization failures as redirects or 403-style flows,
- distinguish empty states, user errors, and system failures,
- isolate failures at useful boundaries instead of collapsing the entire page,
- preserve enough observability context to correlate a user-visible error with backend traces or digests.
Observability
Many Next.js apps feel excellent during development but become opaque in production. A production system needs a minimum observability baseline.
At minimum, track
- server-side error logs,
- Route Handler request logs,
- Server Action failures,
- external API latency,
- slow database queries,
- Web Vitals,
- deployment version identifiers,
- trace IDs or request IDs.
Logging principles
- prefer structured logs,
- avoid sensitive payload logging,
- attach request IDs to important flows,
- include enough context in errors:
- scenario,
- sanitized user context,
- resource identifier,
- failure stage,
- digest or trace correlation data.
Web Vitals
At minimum, collect and analyze:
- LCP,
- INP,
- CLS.
Correlate them with route, device class, and deployed version.
Production recommendations
- add audit logs for critical mutations,
- instrument cache invalidation behavior,
- distinguish user input errors from infrastructure failures,
- classify third-party outages separately from generic 500s.
Testing and engineering quality
Recommended testing layers
1. Unit tests
Use for:
- pure functions,
- validation schemas,
- DTO mapping,
- service logic,
- policy and authorization rules.
2. Integration tests
Use for:
- Route Handlers,
- Server Actions,
- data access and invalidation flows,
- authentication and authorization behavior.
3. End-to-end tests
Use for:
- sign-in,
- critical product workflows,
- core submission paths,
- error and empty states,
- key SEO-visible routes.
CI quality baseline
At minimum, enforce in CI:
- TypeScript checks,
- ESLint,
- unit tests,
- production build validation,
- core E2E smoke coverage.
Code review checklist for Next.js code
Reviewers should specifically look for:
- unnecessary
'use client', - database entities being passed directly to UI,
- cache strategy scattered across pages,
- missing authorization in Server Actions,
- sequential requests that create waterfalls,
- middleware being used as a business layer,
- sensitive fields leaking into client-facing data.
Deployment
Prefer platforms that match the runtime model
Vercel provides the most complete out-of-the-box support for Next.js, but it is not the only valid target. Regardless of platform, confirm these runtime assumptions:
- Node runtime vs Edge runtime,
- image optimization support,
- cache and revalidation behavior,
- Server Action support,
- read-only filesystem assumptions,
- cold start and region strategy.
Build and release recommendations
- include a version or commit SHA in every deployment,
- keep build artifacts immutable,
- isolate environment variables by environment,
- separate production, staging, and development configuration,
- run post-deploy health checks automatically.
Docker example
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN corepack enable && pnpm build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 3000
CMD ["node", "server.js"]Release checklist
- validate key cache hit and invalidation paths,
- confirm rollback steps,
- verify database migration order,
- ensure monitoring, alerting, and logging point to the new release,
- validate sitemap, robots, and metadata behavior.
Common anti-patterns
These issues are common in real teams, and they usually reflect architectural confusion rather than isolated code mistakes.
1. Making the whole page a Client Component
Consequences:
- larger bundles,
- worse initial performance,
- loss of server rendering advantages,
- unnecessary browser-side data fetching.
2. Passing ORM entities straight into the client
Consequences:
- field leakage,
- strong coupling between UI and schema,
- elevated risk of exposing sensitive data.
3. Declaring cache behavior everywhere ad hoc
Consequences:
- inconsistent behavior for similar resources,
- difficult debugging,
- unclear invalidation behavior.
4. Using middleware for complex authorization
Consequences:
- worse performance,
- lower testability,
- duplicate or conflicting security logic.
5. Making every mutation an API endpoint called with client fetch
Consequences:
- unnecessary boilerplate,
- fragmented invalidation behavior,
- extra complexity for in-app forms.
6. Making every mutation a Server Action
Consequences:
- weak external API boundaries,
- awkward integration with other clients,
- blurred protocol architecture.
7. Sequentially awaiting independent requests
Consequences:
- higher TTFB,
- avoidable blocking.
8. Doing authentication without authorization
Consequences:
- signed-in users can still reach forbidden actions,
- serious but subtle permission bugs.
9. Returning generic failure states with no recovery path
Consequences:
- poor UX,
- weak diagnostics,
- unhelpful operational data.
Team checklist
This checklist works well in PR templates, architecture reviews, or team conventions docs.
Routing and components
- Is the route using Server Components by default?
- Is
'use client'introduced only where necessary? - Are client boundaries small and intentional?
- Does the page avoid direct dependence on database entities?
Data and caching
- Are query functions centralized?
- Is cache behavior defined consistently at the module level?
- Is the correct strategy used:
force-cache,revalidate, orno-store? - Do mutations call
revalidateTagorrevalidatePathappropriately?
Performance
- Have avoidable waterfalls been removed?
- Are slow sections isolated with Suspense?
- Has client-side JavaScript been minimized?
- Are images, fonts, and heavy modules optimized appropriately?
Security
- Is all input validated?
- Is every write path authorized?
- Are sensitive fields excluded from client-visible data?
- Is middleware limited to lightweight boundary work?
Quality
- Are loading, error, and not-found states implemented?
- Is there at least baseline logging and monitoring?
- Are critical flows covered by unit, integration, or E2E tests?
- Does the change pass type checks, linting, build, and core tests?
Release readiness
- Have runtime and environment assumptions been validated?
- Has cache and revalidation behavior been verified?
- Is rollback defined?
- Can production issues be traced to the deployed version?
Related resources
- Next.js Documentation
- App Router Documentation
- Data Fetching, Caching, and Revalidating
- Server Actions
- Metadata API
- Next.js Learn
- Vercel Architecture Guides
Closing thought
A high-quality Next.js 15 codebase is not defined by simply “using App Router.” It is defined by whether the team can establish durable conventions around server-first rendering, cache design, mutation boundaries, security, performance, and operational visibility.
If your team remembers only one sentence, let it be this:
Default to Server First, define boundaries explicitly, and treat caching, security, and observability as first-class architecture concerns.