Next.js — Best Practices & Patterns
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.
| 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. |
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.
| Mistake | Recommended Approach | Why |
|---|---|---|
| useEffect for initial data fetch | Fetch directly in Server Component | Avoids client JS, waterfall requests, and hydration mismatches |
| 'use client' on entire page | Keep 'use client' as low in the tree as possible | Preserves Server Component payload and bundle size wins |
| Unpinned dynamic segments | Use generateStaticParams where possible | Builds pages ahead of time; runtime path handling is a fallback |
| Large unoptimized images | Always use next/image | Automatic sizing, lazy loading, and modern formats |
| Secrets in client components | Read env vars only in Server Components / API routes | NEXT_PUBLIC_ leaks to the browser |
| Ignoring revalidate / cache headers | Set explicit cache policies per route | Prevents stale data and surprise cache behavior |
| Inline route handlers everywhere | Colocate route handlers under app/api/* | Cleaner URLs, shared validation, consistent middleware |
| Heavy context providers at root | Wrap only client subtrees that need state | Prevents forcing the whole page into client mode |
warning
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.
| 1 | // Good — Server Component fetches data, renders plain JSX |
| 2 | // app/blog/[slug]/page.tsx |
| 3 | import { notFound } from 'next/navigation'; |
| 4 | import { getPost } from '@/lib/posts'; |
| 5 | |
| 6 | interface PostPageProps { |
| 7 | params: Promise<{ slug: string }>; |
| 8 | } |
| 9 | |
| 10 | export 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.
| 1 | // Good — typed params/searchParams and explicit metadata |
| 2 | import type { Metadata } from 'next'; |
| 3 | |
| 4 | interface SearchPageProps { |
| 5 | params: Promise<{ store: string }>; |
| 6 | searchParams: Promise<{ q?: string; page?: string }>; |
| 7 | } |
| 8 | |
| 9 | export async function generateMetadata( |
| 10 | { params }: SearchPageProps, |
| 11 | ): Promise<Metadata> { |
| 12 | const { store } = await params; |
| 13 | return { |
| 14 | title: `Search — ${store}`, |
| 15 | }; |
| 16 | } |
| 17 | |
| 18 | export 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
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.
| 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.
| 1 | // app/layout.tsx — root server layout |
| 2 | import type { Metadata } from 'next'; |
| 3 | import { Geist_Mono } from 'next/font/google'; |
| 4 | import { Providers } from '@/components/providers'; |
| 5 | import '@/app/globals.css'; |
| 6 | |
| 7 | export const metadata: Metadata = { |
| 8 | title: { template: '%s | ForgeLearn', default: 'ForgeLearn' }, |
| 9 | description: 'Engineering documentation for builders.', |
| 10 | }; |
| 11 | |
| 12 | export 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 | |
| 29 | import { ThemeProvider } from 'next-themes'; |
| 30 | |
| 31 | export function Providers({ children }: { children: React.ReactNode }) { |
| 32 | return ( |
| 33 | <ThemeProvider attribute="class" defaultTheme="dark"> |
| 34 | {children} |
| 35 | </ThemeProvider> |
| 36 | ); |
| 37 | } |
info
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.
| 1 | // Default: cached at build time / revalidated by ISR |
| 2 | export 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 |
| 11 | async 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 |
| 19 | async 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 |
| 27 | async 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.
| 1 | // app/actions/contact.ts |
| 2 | 'use server'; |
| 3 | |
| 4 | import { revalidatePath } from 'next/cache'; |
| 5 | import { z } from 'zod'; |
| 6 | |
| 7 | const contactSchema = z.object({ |
| 8 | email: z.string().email(), |
| 9 | message: z.string().min(10).max(2000), |
| 10 | }); |
| 11 | |
| 12 | export 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 | |
| 34 | import { useActionState } from 'react'; |
| 35 | import { submitContact } from '@/app/actions/contact'; |
| 36 | |
| 37 | export 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
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.
| 1 | // Image optimization with next/image |
| 2 | import Image from 'next/image'; |
| 3 | import hero from '@/public/hero.jpg'; |
| 4 | |
| 5 | export 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 |
| 21 | import { Geist_Mono } from 'next/font/google'; |
| 22 | |
| 23 | const mono = Geist_Mono({ subsets: ['latin'] }); |
| 24 | |
| 25 | export 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.
| 1 | // Lazy-load a charting library only on the client |
| 2 | import dynamic from 'next/dynamic'; |
| 3 | |
| 4 | const Chart = dynamic(() => import('@/components/chart'), { |
| 5 | ssr: false, |
| 6 | loading: () => <p>Loading chart...</p>, |
| 7 | }); |
| 8 | |
| 9 | export 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 | |
| 21 | import { useMemo } from 'react'; |
| 22 | |
| 23 | export 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
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.
| 1 | // next.config.ts — security headers and CSP |
| 2 | import type { NextConfig } from 'next'; |
| 3 | |
| 4 | const 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 | |
| 17 | const 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 | |
| 40 | export 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.
| 1 | // src/middleware.ts |
| 2 | import { NextResponse } from 'next/server'; |
| 3 | import type { NextRequest } from 'next/server'; |
| 4 | |
| 5 | export 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 | |
| 18 | export const config = { |
| 19 | matcher: ['/dashboard/:path*', '/account/:path*'], |
| 20 | }; |
danger
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.
| 1 | // lib/posts.test.ts — unit test the data layer |
| 2 | import { getPost } from '@/lib/posts'; |
| 3 | |
| 4 | jest.mock('next/headers', () => ({ |
| 5 | cookies: jest.fn(() => ({ get: jest.fn() })), |
| 6 | })); |
| 7 | |
| 8 | describe('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 |
| 16 | import { render, screen, fireEvent } from '@testing-library/react'; |
| 17 | import { Counter } from './counter'; |
| 18 | |
| 19 | it('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
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.
| 1 | // Use semantic elements and proper heading order |
| 2 | export 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 |
| 15 | import DOMPurify from 'isomorphic-dompurify'; |
| 16 | |
| 17 | export 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 |
| 23 | import Link from 'next/link'; |
| 24 | |
| 25 | export 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.
| 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 | // } |
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.
| 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.
| 1 | // eslint.config.mjs — Next.js flat config |
| 2 | import { dirname } from 'path'; |
| 3 | import { fileURLToPath } from 'url'; |
| 4 | import { FlatCompat } from '@eslint/eslintrc'; |
| 5 | |
| 6 | const __filename = fileURLToPath(import.meta.url); |
| 7 | const __dirname = dirname(__filename); |
| 8 | |
| 9 | const compat = new FlatCompat({ baseDirectory: __dirname }); |
| 10 | |
| 11 | const 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 | |
| 21 | export default eslintConfig; |
info
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.
| 1 | // next.config.ts — Docker / self-host |
| 2 | import type { NextConfig } from 'next'; |
| 3 | |
| 4 | const nextConfig: NextConfig = { |
| 5 | output: 'standalone', |
| 6 | poweredByHeader: false, |
| 7 | images: { |
| 8 | remotePatterns: [ |
| 9 | { protocol: 'https', hostname: 'cdn.example.com' }, |
| 10 | ], |
| 11 | }, |
| 12 | }; |
| 13 | |
| 14 | export default nextConfig; |
| 15 | |
| 16 | # See the Next.js docs for a production Dockerfile template. |
warning
Use this checklist before merging Next.js changes. Automate what you can with CI; reserve human review for architecture, data flow, and security decisions.
| Check | Automated? | Details |
|---|---|---|
| TypeScript strict | npx tsc --noEmit | No implicit any, typed route props |
| Lint | npm run lint | Next.js core-web-vitals + typescript rules |
| Build | npm run build | Zero warnings, static params valid |
| Client boundary scope | Review | 'use client' only where interactivity lives |
| Data fetching layer | Review | Fetch in Server Component, cache explicit |
| Env var exposure | grep / review | No NEXT_PUBLIC_ secrets |
| Image optimization | Review | next/image, sizes, priority where needed |
| Security headers | Review / headers check | CSP, X-Frame-Options, HSTS configured |
| Input validation | Review | Server Actions and API routes validate input |
| Accessibility | axe / Review | Semantic HTML, alt text, focus order |
| Bundle size | bundle-analyzer | No accidental server-only deps in client |
| Metadata | Review | Titles, descriptions, canonical, OG tags |
| 1 | # Pre-merge CI pipeline |
| 2 | npx tsc --noEmit |
| 3 | npm run lint |
| 4 | npm run build |
| 5 | # npx @next/bundle-analyzer npm run build |
| 6 | # npm run test:unit |
| 7 | # npm run test:e2e |