|$ curl https://forge-ai.dev/api/markdown?path=docs/nextjs/best-practices
$cat docs/next.js-—-best-practices-&-patterns.md
updated Recently·22 min read·published

Next.js — Best Practices & Patterns

Next.jsAdvanced🎯Free Tools
Introduction

Next.js 16 reimagines full-stack React around the App Router, React Server Components, and the edge-first routing model. Writing production-grade Next.js means understanding where code runs (server vs client), how data fetching and caching are orchestrated, and which features unlock performance rather than accidentally degrade it.

This guide collects the practices that separate demo-grade apps from production-grade sites: from route layout hygiene and Server Components, to cache policies, image optimization, security headers, testing strategies, and deployment checks. Treat these as guardrails, then adapt them to your team's scale and constraints.

app-router-model.tsx
TSX
1// The App Router mental model in one file tree
2//
3// src/app/
4// layout.tsx # root server layout (HTML shell, providers)
5// page.tsx # route "/"
6// loading.tsx # shared loading UI
7// error.tsx # shared error boundary
8// not-found.tsx # 404 fallback
9// blog/
10// page.tsx # route "/blog"
11// [slug]/
12// page.tsx # dynamic route "/blog/:slug"
13// loading.tsx # local loading UI
14// error.tsx # local error boundary
15//
16// Server Components are the default. Mark a client boundary
17// only where interactivity is required.
Dos and Don'ts

The most expensive mistakes in Next.js are architectural: fetching data in the wrong component, leaking client code into server bundles, or misunderstanding the cache. Use the table below as a quick reference before writing new routes.

MistakeRecommended ApproachWhy
useEffect for initial data fetchFetch directly in Server ComponentAvoids client JS, waterfall requests, and hydration mismatches
'use client' on entire pageKeep 'use client' as low in the tree as possiblePreserves Server Component payload and bundle size wins
Unpinned dynamic segmentsUse generateStaticParams where possibleBuilds pages ahead of time; runtime path handling is a fallback
Large unoptimized imagesAlways use next/imageAutomatic sizing, lazy loading, and modern formats
Secrets in client componentsRead env vars only in Server Components / API routesNEXT_PUBLIC_ leaks to the browser
Ignoring revalidate / cache headersSet explicit cache policies per routePrevents stale data and surprise cache behavior
Inline route handlers everywhereColocate route handlers under app/api/*Cleaner URLs, shared validation, consistent middleware
Heavy context providers at rootWrap only client subtrees that need statePrevents forcing the whole page into client mode

warning

Avoid putting 'use client' at the top of large page files just because one small button needs state. Lift the interactive piece into its own component and wrap only that component. The default in the App Router is Server Components for a reason: zero client JS for static reads.
Code Style & Conventions

Next.js inherits React and TypeScript conventions, but adds App Router-specific patterns. Keep files focused, name routes semantically, and prefer async/await over promise chains for data fetching in Server Components.

server-component-fetch.tsx
TSX
1// Good — Server Component fetches data, renders plain JSX
2// app/blog/[slug]/page.tsx
3import { notFound } from 'next/navigation';
4import { getPost } from '@/lib/posts';
5
6interface PostPageProps {
7 params: Promise<{ slug: string }>;
8}
9
10export default async function PostPage({ params }: PostPageProps) {
11 const { slug } = await params;
12 const post = await getPost(slug);
13
14 if (!post) {
15 notFound();
16 }
17
18 return (
19 <article>
20 <h1>{post.title}</h1>
21 <p>{post.excerpt}</p>
22 </article>
23 );
24}
25
26// Bad — unnecessary client boundary and useEffect fetch
27// 'use client';
28// import { useEffect, useState } from 'react';
29// export default function PostPage({ params }) {
30// const [post, setPost] = useState(null);
31// useEffect(() => {
32// fetch(`/api/posts/${params.slug}`).then(r => r.json()).then(setPost);
33// }, [params.slug]);
34// ...
35// }

In TypeScript strict mode, type params and searchParams as Promise because Next.js 16 passes them asynchronously. Use named exports for metadata and route handlers, and keep default exports reserved for page components.

typed-route-props.tsx
TSX
1// Good — typed params/searchParams and explicit metadata
2import type { Metadata } from 'next';
3
4interface SearchPageProps {
5 params: Promise<{ store: string }>;
6 searchParams: Promise<{ q?: string; page?: string }>;
7}
8
9export async function generateMetadata(
10 { params }: SearchPageProps,
11): Promise<Metadata> {
12 const { store } = await params;
13 return {
14 title: `Search — ${store}`,
15 };
16}
17
18export default async function SearchPage({
19 params,
20 searchParams,
21}: SearchPageProps) {
22 const { store } = await params;
23 const { q = '', page = '1' } = await searchParams;
24
25 return (
26 <section>
27 <h1>Store: {store}</h1>
28 <p>Query: {q}</p>
29 <p>Page: {page}</p>
30 </section>
31 );
32}

best practice

Keep page components free of business logic. Move data access into a lib/ or services/ layer with explicit return types. Pages should read like a wiring diagram: route input → fetch → render.
Project Structure

A predictable folder structure reduces cognitive load and makes caching and data ownership obvious. Group by domain or feature when the app is large, and by technical role (components, lib, hooks) for smaller sites.

project-structure.sh
Bash
1# Recommended App Router structure (mid-size project)
2#
3# src/
4# app/
5# (marketing)/ # route group, no URL segment
6# page.tsx
7# about/page.tsx
8# (dashboard)/
9# layout.tsx # dashboard-specific shell
10# dashboard/page.tsx
11# settings/page.tsx
12# api/
13# posts/route.ts # GET /api/posts
14# posts/[id]/route.ts # GET /api/posts/:id
15# layout.tsx # root layout
16# page.tsx # landing
17# loading.tsx
18# error.tsx
19# not-found.tsx
20# globals.css
21# components/
22# ui/ # generic buttons, inputs, cards
23# docs/ # domain-specific doc components
24# providers.tsx # client-only providers root
25# lib/
26# db.ts # database client (server-only)
27# auth.ts # server auth helpers
28# cache.ts # cache tag helpers
29# utils.ts # shared utilities
30# hooks/
31# use-media-query.ts
32# types/
33# index.ts
34#
35# Use route groups to split layouts without polluting URLs.

Keep Server Components and Client Components in separate files when possible. A file with 'use client' turns every child into a client component, even if they don't need interactivity. The naming convention *.client.tsx is optional but signals intent clearly.

root-layout.tsx
TSX
1// app/layout.tsx — root server layout
2import type { Metadata } from 'next';
3import { Geist_Mono } from 'next/font/google';
4import { Providers } from '@/components/providers';
5import '@/app/globals.css';
6
7export const metadata: Metadata = {
8 title: { template: '%s | ForgeLearn', default: 'ForgeLearn' },
9 description: 'Engineering documentation for builders.',
10};
11
12export default function RootLayout({
13 children,
14}: {
15 children: React.ReactNode;
16}) {
17 return (
18 <html lang="en">
19 <body className="antialiased">
20 <Providers>{children}</Providers>
21 </body>
22 </html>
23 );
24}
25
26// components/providers.tsx — minimal client boundary
27'use client';
28
29import { ThemeProvider } from 'next-themes';
30
31export function Providers({ children }: { children: React.ReactNode }) {
32 return (
33 <ThemeProvider attribute="class" defaultTheme="dark">
34 {children}
35 </ThemeProvider>
36 );
37}

info

Place heavy third-party scripts (analytics, ads) using the next/script component with the appropriate loading strategy. For critical scripts, prefer strategy="beforeInteractive" in the root layout; for analytics, use strategy="afterInteractive".
Data Fetching & Caching

Next.js caches fetch responses, layouts, and pages by default in production. Understand the four caches — Request Memoization, Data Cache, Full Route Cache, and Router Cache — so you can opt out deliberately rather than accidentally.

fetch-caching.tsx
TSX
1// Default: cached at build time / revalidated by ISR
2export async function generateStaticParams() {
3 const posts = await fetch('https://api.example.com/posts').then((r) =>
4 r.json(),
5 );
6
7 return posts.map((post: { id: string }) => ({ id: post.id }));
8}
9
10// Opt-out of data cache for user-specific data
11async function getCart(userId: string) {
12 const res = await fetch(`https://api.example.com/cart/${userId}`, {
13 cache: 'no-store', // dynamic, never cached
14 });
15 return res.json();
16}
17
18// ISR — revalidate at most every 60 seconds
19async function getProducts() {
20 const res = await fetch('https://api.example.com/products', {
21 next: { revalidate: 60 },
22 });
23 return res.json();
24}
25
26// Revalidate on-demand via tag
27async function getPost(slug: string) {
28 const res = await fetch(`https://api.example.com/posts/${slug}`, {
29 next: { tags: ['posts', `post-${slug}`] },
30 });
31 return res.json();
32}
33
34// In a route handler or server action:
35// import { revalidateTag } from 'next/cache';
36// revalidateTag('posts');

Server Actions let you mutate data from Client Components without writing API routes. Use them for form submissions and small mutations, but keep them focused and validate inputs on the server.

server-action.tsx
TSX
1// app/actions/contact.ts
2'use server';
3
4import { revalidatePath } from 'next/cache';
5import { z } from 'zod';
6
7const contactSchema = z.object({
8 email: z.string().email(),
9 message: z.string().min(10).max(2000),
10});
11
12export async function submitContact(
13 _prevState: { message: string },
14 formData: FormData,
15) {
16 const parsed = contactSchema.safeParse({
17 email: formData.get('email'),
18 message: formData.get('message'),
19 });
20
21 if (!parsed.success) {
22 return { message: 'Invalid input.' };
23 }
24
25 await saveMessage(parsed.data);
26 revalidatePath('/contact');
27
28 return { message: 'Message sent.' };
29}
30
31// components/contact-form.tsx
32'use client';
33
34import { useActionState } from 'react';
35import { submitContact } from '@/app/actions/contact';
36
37export function ContactForm() {
38 const [state, action, pending] = useActionState(submitContact, {
39 message: '',
40 });
41
42 return (
43 <form action={action}>
44 <input name="email" type="email" required />
45 <textarea name="message" required />
46 <button type="submit" disabled={pending}>
47 {pending ? 'Sending...' : 'Send'}
48 </button>
49 {state.message && <p>{state.message}</p>}
50 </form>
51 );
52}

best practice

Always validate inputs inside Server Actions, even if the client also validates. Never trust the client. Use zod, valibot, or a similar schema library and return safe, non-leaking error messages.
Performance

Performance in Next.js is a combination of rendering strategy, asset optimization, and bundle control. Prefer static generation for content that can be pre-rendered, and dynamic rendering only when the request truly depends on user-specific or uncacheable data.

image-fonts.tsx
TSX
1// Image optimization with next/image
2import Image from 'next/image';
3import hero from '@/public/hero.jpg';
4
5export default function Hero() {
6 return (
7 <Image
8 src={hero}
9 alt="Developers building with Next.js"
10 priority
11 placeholder="blur"
12 sizes="(max-width: 768px) 100vw, 50vw"
13 />
14 );
15}
16
17// Lazy-load below-the-fold images
18// priority={false} (default) + sizes prop for responsive art direction
19
20// Fonts — avoid layout shift
21import { Geist_Mono } from 'next/font/google';
22
23const mono = Geist_Mono({ subsets: ['latin'] });
24
25export default function CodeBlock({ children }: { children: React.ReactNode }) {
26 return <pre className={mono.className}>{children}</pre>;
27}

Code splitting happens automatically at route boundaries, but you can add dynamic imports for heavy components that are not needed on first paint. Use next/dynamic with ssr: false only for components that truly need the browser.

code-splitting.tsx
TSX
1// Lazy-load a charting library only on the client
2import dynamic from 'next/dynamic';
3
4const Chart = dynamic(() => import('@/components/chart'), {
5 ssr: false,
6 loading: () => <p>Loading chart...</p>,
7});
8
9export default function DashboardPage() {
10 return (
11 <main>
12 <h1>Dashboard</h1>
13 <Chart />
14 </main>
15 );
16}
17
18// Memoize expensive computations inside Client Components
19'use client';
20
21import { useMemo } from 'react';
22
23export function DataTable({ rows }: { rows: Row[] }) {
24 const sorted = useMemo(
25 () => rows.slice().sort((a, b) => b.score - a.score),
26 [rows],
27 );
28
29 return <table>...</table>;
30}

info

Use the Next.js bundle analyzer to find large dependencies. Run npx @next/bundle-analyzer on your production build and question any library larger than ~50 kB that is not pulling its weight.
Security

Security starts with defense in depth: headers, input validation, safe rendering, and least-privilege environment variables. Next.js gives you middleware and configuration hooks for headers; use them.

security-headers.ts
TypeScript
1// next.config.ts — security headers and CSP
2import type { NextConfig } from 'next';
3
4const cspHeader = `
5 default-src 'self';
6 script-src 'self';
7 style-src 'self' 'unsafe-inline';
8 img-src 'self' blob: data: https://cdn.example.com;
9 font-src 'self';
10 object-src 'none';
11 base-uri 'self';
12 form-action 'self';
13 frame-ancestors 'none';
14 upgrade-insecure-requests;
15`;
16
17const nextConfig: NextConfig = {
18 async headers() {
19 return [
20 {
21 source: '/(.*)',
22 headers: [
23 {
24 key: 'Content-Security-Policy',
25 value: cspHeader.replace(/\s+/g, ' ').trim(),
26 },
27 { key: 'X-Frame-Options', value: 'DENY' },
28 { key: 'X-Content-Type-Options', value: 'nosniff' },
29 { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
30 {
31 key: 'Permissions-Policy',
32 value: 'camera=(), microphone=(), geolocation=()',
33 },
34 ],
35 },
36 ];
37 },
38};
39
40export default nextConfig;
41
42// Note: for a real nonce-based CSP, generate the nonce per request
43// in middleware and inject it into the response headers and <head>.

Middleware is useful for authentication redirects, A/B testing, and geolocation, but it runs on every matching request. Keep it fast and avoid database calls when possible. Use matchers to limit the routes it executes on.

middleware.ts
TypeScript
1// src/middleware.ts
2import { NextResponse } from 'next/server';
3import type { NextRequest } from 'next/server';
4
5export function middleware(request: NextRequest) {
6 const token = request.cookies.get('session')?.value;
7 const isAuthRoute = request.nextUrl.pathname.startsWith('/dashboard');
8
9 if (isAuthRoute && !token) {
10 const login = new URL('/login', request.url);
11 login.searchParams.set('from', request.nextUrl.pathname);
12 return NextResponse.redirect(login);
13 }
14
15 return NextResponse.next();
16}
17
18export const config = {
19 matcher: ['/dashboard/:path*', '/account/:path*'],
20};

danger

Never expose database credentials, API secrets, or private keys as NEXT_PUBLIC_ environment variables. Those are embedded in the client bundle. Keep server secrets in plain process.env.VAR and access them only from Server Components, Route Handlers, or Server Actions.
Testing

The project has no test framework, but documenting the testing strategy matters. Test Server Components by verifying the data layer and utilities they call. Test Client Components with React Testing Library and mock fetch via MSW. Use end-to-end tests for critical user journeys.

testing-strategy.tsx
TSX
1// lib/posts.test.ts — unit test the data layer
2import { getPost } from '@/lib/posts';
3
4jest.mock('next/headers', () => ({
5 cookies: jest.fn(() => ({ get: jest.fn() })),
6}));
7
8describe('getPost', () => {
9 it('returns null for unknown slugs', async () => {
10 const result = await getPost('does-not-exist');
11 expect(result).toBeNull();
12 });
13});
14
15// components/counter.test.tsx — client component test
16import { render, screen, fireEvent } from '@testing-library/react';
17import { Counter } from './counter';
18
19it('increments on click', () => {
20 render(<Counter initial={0} />);
21 fireEvent.click(screen.getByRole('button', { name: /increment/i }));
22 expect(screen.getByText('1')).toBeInTheDocument();
23});
24
25// E2E with Playwright
26// tests/checkout.spec.ts
27// test('completes checkout', async ({ page }) => {
28// await page.goto('/checkout');
29// await page.fill('[name="email"]', 'user@example.com');
30// await page.click('text=Pay');
31// await expect(page.locator('text=Order confirmed')).toBeVisible();
32// });
📝

note

Server Components render to an async payload, so mounting them directly in a test renderer is awkward. Instead, extract pure logic and data fetching helpers into regular async functions and unit-test those. Reserve component-level testing for Client Components and use E2E for full-page flows.
Accessibility

Accessibility is not a post-launch checklist. Build it into components and routes from the start: semantic HTML, focus management, keyboard navigation, and meaningful page metadata.

accessibility.tsx
TSX
1// Use semantic elements and proper heading order
2export default function BlogPost({ post }: { post: Post }) {
3 return (
4 <article>
5 <header>
6 <h1>{post.title}</h1>
7 <p>Published <time dateTime={post.publishedAt}>{post.date}</time></p>
8 </header>
9 <div dangerouslySetInnerHTML={{ __html: post.body }} />
10 </article>
11 );
12}
13
14// Sanitize HTML before rendering to avoid XSS
15import DOMPurify from 'isomorphic-dompurify';
16
17export async function getPost(slug: string) {
18 const raw = await fetchPost(slug);
19 return { ...raw, body: DOMPurify.sanitize(raw.body) };
20}
21
22// next/link preserves focus and prefetching
23import Link from 'next/link';
24
25export function Nav() {
26 return (
27 <nav aria-label="Main">
28 <Link href="/">Home</Link>
29 <Link href="/docs">Docs</Link>
30 </nav>
31 );
32}

Dynamic routes should set the language attribute and route-specific metadata. Use the priority prop on the Largest Contentful Paint image, and never auto-focus elements without explicit user intent.

a11y-focus.tsx
TSX
1// app/layout.tsx — lang attribute for screen readers
2<html lang="en">
3 <body>...</body>
4</html>
5
6// Focus-visible styles in globals.css
7// :focus-visible {
8// outline: 2px solid var(--green);
9// outline-offset: 2px;
10// }
11//
12// :focus:not(:focus-visible) {
13// outline: none;
14// }
Tooling

A solid toolchain catches mistakes before they ship. Use TypeScript in strict mode, ESLint with the Next.js preset, and format with Prettier or a compatible formatter. Keep the build output clean — warnings ignored become errors later.

tsconfig.json
JSON
1// tsconfig.json — strict mode required
2{
3 "compilerOptions": {
4 "target": "ES2017",
5 "lib": ["dom", "dom.iterable", "esnext"],
6 "allowJs": true,
7 "skipLibCheck": true,
8 "strict": true,
9 "noEmit": true,
10 "esModuleInterop": true,
11 "module": "esnext",
12 "moduleResolution": "bundler",
13 "resolveJsonModule": true,
14 "isolatedModules": true,
15 "jsx": "preserve",
16 "incremental": true,
17 "plugins": [{ "name": "next" }],
18 "paths": { "@/*": ["./src/*"] }
19 },
20 "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
21 "exclude": ["node_modules"]
22}

Add the Next.js recommended ESLint config and enable import rules. Use the Turbopack dev server where possible and validate production builds with next build in CI.

eslint.config.mjs
JavaScript
1// eslint.config.mjs — Next.js flat config
2import { dirname } from 'path';
3import { fileURLToPath } from 'url';
4import { FlatCompat } from '@eslint/eslintrc';
5
6const __filename = fileURLToPath(import.meta.url);
7const __dirname = dirname(__filename);
8
9const compat = new FlatCompat({ baseDirectory: __dirname });
10
11const eslintConfig = [
12 ...compat.extends('next/core-web-vitals', 'next/typescript'),
13 {
14 rules: {
15 '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
16 'import/no-anonymous-default-export': 'error',
17 },
18 },
19];
20
21export default eslintConfig;

info

Run next build in CI before merging. It surfaces TypeScript errors, route conflicts, and static analysis warnings that next dev can miss. Treat every build warning as a bug to fix.
Deployment

Next.js can deploy anywhere with Node.js, Docker, or serverless platforms. Match your output mode to your hosting target: output: 'standalone' for Docker, default for Vercel/Netlify-style platforms, and output: 'export' only for fully static sites.

deployment.ts
TypeScript
1// next.config.ts — Docker / self-host
2import type { NextConfig } from 'next';
3
4const nextConfig: NextConfig = {
5 output: 'standalone',
6 poweredByHeader: false,
7 images: {
8 remotePatterns: [
9 { protocol: 'https', hostname: 'cdn.example.com' },
10 ],
11 },
12};
13
14export default nextConfig;
15
16# See the Next.js docs for a production Dockerfile template.

warning

Static export (output: 'export') disables API routes, Server Actions, and dynamic routing with rewrites. Only use it when every page can be generated at build time and no server runtime is required.
Code Review Checklist

Use this checklist before merging Next.js changes. Automate what you can with CI; reserve human review for architecture, data flow, and security decisions.

CheckAutomated?Details
TypeScript strictnpx tsc --noEmitNo implicit any, typed route props
Lintnpm run lintNext.js core-web-vitals + typescript rules
Buildnpm run buildZero warnings, static params valid
Client boundary scopeReview'use client' only where interactivity lives
Data fetching layerReviewFetch in Server Component, cache explicit
Env var exposuregrep / reviewNo NEXT_PUBLIC_ secrets
Image optimizationReviewnext/image, sizes, priority where needed
Security headersReview / headers checkCSP, X-Frame-Options, HSTS configured
Input validationReviewServer Actions and API routes validate input
Accessibilityaxe / ReviewSemantic HTML, alt text, focus order
Bundle sizebundle-analyzerNo accidental server-only deps in client
MetadataReviewTitles, descriptions, canonical, OG tags
ci-pipeline.sh
Bash
1# Pre-merge CI pipeline
2npx tsc --noEmit
3npm run lint
4npm run build
5# npx @next/bundle-analyzer npm run build
6# npm run test:unit
7# npm run test:e2e
$Blueprint — Engineering Documentation·Section ID: NEXTJS-BP·Revision: 1.0