# Next.js Style Guide

> Conventions for writing, structuring, formatting and shipping Next.js 16 apps with the App Router, strict TypeScript, Cache Components and StyleX.

Revision 2026.10, released 2026-10-19. This pinned URL never changes; the latest text is at https://style.chopper.dev/nextjs/llms.txt. Changelog: https://style.chopper.dev/nextjs/changelog.md.

Rule levels: MUST / MUST NOT (non-negotiable), SHOULD / SHOULD NOT (the default; deviate only with a short comment saying why), MAY (allowed when it helps), AVOID (allowed but a smell). In this document they appear as a bracketed prefix on each rule, after its stable ID, e.g. "`NEXTJS-02.1` [MUST] ...". Cite a rule by its ID (NEXTJS-02.1): IDs never change or get reused, and https://style.chopper.dev/nextjs@2026.10#NEXTJS-02.1 links to the rule. Code samples use a placeholder app called Project with an `invoices` feature. Substitute your own names.

## Starter project

A starter project that follows this guide exists, but templates are not versioned: see the latest guide at https://style.chopper.dev/nextjs/llms.txt for its download URL and parameters.

## Contents

- 01. Purpose and scope
- 02. Baseline and toolchain
- 03. Project layout
- 04. Naming and file conventions
- 05. Formatting and linting
- 06. TypeScript
- 07. Server and Client Components
- 08. Routing and layouts
- 09. Data fetching and caching
- 10. Mutations: Server Actions and Route Handlers
- 11. Components and styling
- 12. State management
- 13. Forms
- 14. Errors and loading states
- 15. Security
- 16. Performance
- 17. Accessibility
- 18. Testing
- 19. Build, configuration and deployment
- 20. Comments and documentation
- 21. Git and review
- A. Appendix

## 01. Purpose and scope

Every Next.js project that adopts this guide uses the App Router on Next.js 16.4 or later, TypeScript in strict mode, and the Cache Components model. The goal is that any two projects read as if one person wrote them.

The guide covers layout, naming, formatting, TypeScript, the server/client split, routing, data and caching, mutations, styling, state, forms, security, performance, accessibility, testing, configuration and commits. It does not cover per-project product or architecture decisions; each project documents those in its own `docs/` folder.

**Rule levels.** Each rule carries one of these words:

| Level | Meaning | Breaking it |
| --- | --- | --- |
| [MUST] [MUST NOT] | Non-negotiable. Tooling or review enforces it. | Needs a written reason in the code and the commit. |
| [SHOULD] | The default. Expected in nearly all code. | Fine with a short comment saying why. |
| [MAY] | Allowed when it helps; neither encouraged nor discouraged. | Nothing to break. |
| [AVOID] | Allowed but a smell. | Fine when the alternative is clearly worse. |

**Examples.** Code samples use a placeholder app called Project with an `invoices` feature. Substitute your own names.

**Scope.** The Pages Router is out of scope. Existing Pages Router code is migrated route by route, not extended.

**When the guide is silent,** match the surrounding code, then the official [Next.js documentation](https://nextjs.org/docs) and [React documentation](https://react.dev/), in that order.

## 02. Baseline and toolchain

The baseline is Next.js 16.4 with React 19.3, Turbopack, TypeScript in strict mode, pnpm and an LTS release of Node.js. As of 16.4 the Next.js team recommends Cache Components for every app, and they become the default in Next.js 17 ([Next.js 16.4 release post](https://nextjs.org/blog/next-16-4)).

| Tool | Version policy |
| --- | --- |
| Next.js | 16.4 or later, App Router only |
| React / React DOM | The version Next.js ships with (19.3 for 16.4) |
| Node.js | Current Active LTS; never below 20.9, the Next.js 16 minimum |
| TypeScript | Pinned in `package.json`; never below 5.1, the Next.js 16 minimum |
| Package manager | pnpm, pinned with the `packageManager` field |
| Bundler | Turbopack (the default since Next.js 16); webpack only with a written reason |
| Lint / format | ESLint (flat config) and Prettier; `next lint` no longer exists |

**Rules**

- `NEXTJS-02.1` [MUST] enable Cache Components and Partial Prefetching (`cacheComponents: true`, `partialPrefetching: true`) in new apps. Existing apps migrate to them as one tracked task.
- `NEXTJS-02.2` [MUST] pin Node.js in `.nvmrc` and the `engines` field, and pnpm in `packageManager`, so local and CI match.
- `NEXTJS-02.3` [MUST] commit `pnpm-lock.yaml` and install with `pnpm install --frozen-lockfile` in CI.
- `NEXTJS-02.4` [MUST NOT] use features flagged `experimental` in `next.config.ts` in production apps without an ADR naming the flag and the exit plan.
- `NEXTJS-02.5` [SHOULD] upgrade minor Next.js releases within a month and apply security releases within days. `next upgrade` and the codemods do most of the work.
- `NEXTJS-02.6` [SHOULD] enable the React Compiler (`reactCompiler: true`) and drop manual `useMemo` / `useCallback` / `memo` except where a profile shows they still help.

**Changes in Next.js 16 this guide assumes** ([Next.js 16 release post](https://nextjs.org/blog/next-16))

| Change | What it means for code |
| --- | --- |
| `params`, `searchParams`, `cookies()`, `headers()`, `draftMode()` are async | Always `await` them |
| `middleware.ts` renamed to `proxy.ts` (Node.js runtime) | Request interception lives in `proxy.ts` exporting `proxy` |
| Caching is opt-in with `'use cache'` | Nothing is cached unless marked |
| `revalidateTag(tag)` needs a profile; `updateTag()` and `refresh()` added | Use `updateTag` in actions for read-your-writes |
| Parallel route slots need `default.tsx` | Builds fail without one |
| `next lint` removed | Run ESLint directly |
| AMP, `serverRuntimeConfig`, `publicRuntimeConfig` removed | Use environment variables |

## 03. Project layout

Routes live in `src/app` and stay thin; the code they render lives in feature folders under `src/features`. A route file composes features, it does not contain business logic.

```text
project/
├── next.config.ts
├── package.json                     # engines, packageManager, scripts
├── eslint.config.mjs
├── prettier.config.mjs
├── tsconfig.json
├── .env.example                     # every variable, no real values
├── public/                          # static files served as-is
├── e2e/                             # Playwright end-to-end tests
└── src/
    ├── proxy.ts                     # request interception (auth redirects, rewrites)
    ├── app/
    │   ├── layout.tsx               # root layout: html, body, fonts, providers
    │   ├── not-found.tsx
    │   ├── global-error.tsx
    │   ├── (marketing)/             # route group: public pages, own layout
    │   │   ├── layout.tsx
    │   │   └── page.tsx
    │   ├── (app)/                   # route group: signed-in app shell
    │   │   ├── layout.tsx
    │   │   └── invoices/
    │   │       ├── page.tsx
    │   │       ├── loading.tsx
    │   │       ├── error.tsx
    │   │       └── [invoiceId]/
    │   │           └── page.tsx
    │   └── api/
    │       └── webhooks/stripe/route.ts
    ├── features/
    │   └── invoices/
    │       ├── components/          # invoice-table.tsx, invoice-status-badge.tsx
    │       ├── actions.ts           # 'use server' mutations
    │       ├── queries.ts           # server-only reads, 'use cache' where safe
    │       ├── schemas.ts           # zod schemas and inferred types
    │       └── invoice-table.test.tsx
    ├── components/ui/               # shared design-system primitives
    ├── lib/                         # db client, auth, env, logger
    └── styles/globals.css
```

- `NEXTJS-03.1` [MUST] use the `src/` directory and the App Router. Route files (`page.tsx`, `layout.tsx` and friends) live only under `src/app`.
- `NEXTJS-03.2` [SHOULD] keep route files short: read params, call a feature query, render feature components. Logic moves to `src/features/<feature>/`.
- `NEXTJS-03.3` [SHOULD] use route groups `(name)` to give sections different layouts without changing URLs.
- `NEXTJS-03.4` [SHOULD] colocate everything a feature needs (components, actions, queries, schemas, tests) in its feature folder.
- `NEXTJS-03.5` [MUST] put tests next to the file they test, named `<name>.test.ts` or `<name>.test.tsx`. End-to-end tests live in `e2e/`.
- `NEXTJS-03.6` [SHOULD] keep `src/components/ui` for generic, design-system primitives (button, dialog, input) that know nothing about features.
- `NEXTJS-03.7` [AVOID] `utils`, `helpers` or `common` folders. Code belongs to the feature that uses it; promote it to `src/lib` only when three features need it.
- `NEXTJS-03.8` [MUST] import across the tree with the `@/` alias (`@/features/invoices/queries`), never long relative paths like `../../../lib/db`.
- `NEXTJS-03.9` [SHOULD] prefix folders inside `src/app` that are not routes with `_` (`_components`) when something must be colocated there, so they are never routable.

## 04. Naming and file conventions

Files and folders are kebab-case, React components are PascalCase, and everything else follows standard TypeScript casing. Next.js special file names are reserved and used only for their purpose.

| Kind | Style | Example |
| --- | --- | --- |
| File and folder | kebab-case | `invoice-table.tsx`, `use-debounce.ts` |
| Route segment | kebab-case; dynamic in brackets, camelCase inside | `invoices/[invoiceId]` |
| Route group | kebab-case in parentheses | `(marketing)` |
| React component | PascalCase noun | `InvoiceTable`, `StatusBadge` |
| Hook | `use` + PascalCase | `useDebouncedValue` |
| Function, variable | camelCase | `formatCurrency`, `invoiceCount` |
| Server Action | camelCase verb | `createInvoice`, `markInvoicePaid` |
| Query function | `get` / `list` + noun | `getInvoice`, `listOverdueInvoices` |
| Type, interface | PascalCase, no `I` or `T` prefix | `Invoice`, `InvoiceTableProps` |
| Zod schema | camelCase + `Schema` | `createInvoiceSchema` |
| Module-level constant | UPPER_SNAKE_CASE for true constants | `MAX_PAGE_SIZE` |
| Environment variable | UPPER_SNAKE_CASE; `NEXT_PUBLIC_` only if browser-visible | `DATABASE_URL`, `NEXT_PUBLIC_SITE_URL` |
| Boolean | `is` / `has` / `can` / `should` prefix | `isPaid`, `hasAccess` |
| Test file | source name + `.test` | `invoice-table.test.tsx` |

**Next.js special files** (reserved names inside `src/app`)

| File | Purpose |
| --- | --- |
| `page.tsx` | Makes a segment routable; renders the UI |
| `layout.tsx` | Shared UI that persists across child routes |
| `loading.tsx` | Suspense fallback for the segment |
| `error.tsx` | Error boundary for the segment (Client Component) |
| `not-found.tsx` | UI for `notFound()` |
| `global-error.tsx` | Root error boundary, replaces the root layout on error |
| `route.ts` | Route Handler (HTTP endpoint); never beside a `page.tsx` |
| `default.tsx` | Fallback for a parallel route slot (required in Next.js 16) |
| `template.tsx` | Like a layout but re-mounts on navigation |

**Rules**

- `NEXTJS-04.1` [MUST] name a component file after its main component in kebab-case: `InvoiceTable` lives in `invoice-table.tsx`.
- `NEXTJS-04.2` [SHOULD] export one main component per file. Small private sub-components may stay in the same file.
- `NEXTJS-04.3` [MUST] use named exports everywhere except where Next.js requires a default export (pages, layouts, special files, `next.config.ts`).
- `NEXTJS-04.4` [MUST] name props types `<Component>Props`.
- `NEXTJS-04.5` [MUST] write acronyms as one capitalized word: `getHttpClient`, `InvoicePdf`, not `getHTTPClient`.
- `NEXTJS-04.6` [SHOULD] make a name as descriptive as its scope is wide, and never abbreviate by deleting letters (`customer`, not `cstmr`).

## 05. Formatting and linting

Prettier decides formatting and ESLint catches mistakes; both run in the editor on save and in CI, and nobody argues about either in review. The configs are in the appendix.

| Topic | Rule |
| --- | --- |
| Indentation | 2 spaces, never tabs |
| Line width | 100 characters (`printWidth: 100`) |
| Quotes | Single quotes in TypeScript, double quotes in JSX attributes |
| Semicolons | Always |
| Trailing commas | Everywhere they are valid (`trailingComma: 'all'`) |
| Braces | Same line; always braced, even for one-line `if` bodies |
| StyleX style keys | Sorted by the `@stylexjs/sort-keys` lint rule |
| Imports | Grouped and sorted automatically (see below) |

**Imports**

Imports are sorted into four groups separated by blank lines: Node built-ins, external packages (with `react` and `next` first), `@/` aliases, then relative imports. Type-only imports use `import type`.

```ts
import { Suspense } from 'react';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { z } from 'zod';

import { InvoiceTable } from '@/features/invoices/components/invoice-table';
import { listInvoices } from '@/features/invoices/queries';
import { Button } from '@/components/ui/button';

import type { InvoiceFilter } from './types';
```

**Rules**

- `NEXTJS-05.1` [MUST] run `prettier --check` and `eslint --max-warnings 0` in CI. A warning that cannot be fixed is disabled on the narrowest line with a comment saying why.
- `NEXTJS-05.2` [MUST] use the ESLint flat config (`eslint.config.mjs`) with the Next.js plugin's `core-web-vitals` rules, `typescript-eslint` type-aware rules, and the React Hooks rules.
- `NEXTJS-05.3` [MUST NOT] disable a lint rule for a whole file or project to make one warning go away.
- `NEXTJS-05.4` [MUST NOT] use default exports outside the places Next.js requires them; the lint config enforces this.
- `NEXTJS-05.5` [MUST] use `import type` for imports used only as types, so they are erased from the bundle.
- `NEXTJS-05.6` [MUST NOT] use barrel files (`index.ts` that re-exports a folder) inside the app; they slow builds and drag unused code into bundles. Import from the file that defines the thing.

## 06. TypeScript

TypeScript runs in strict mode with the extra safety flags on, `any` never appears in app code, and every value that crosses a trust boundary (form data, request bodies, search params, environment variables, third-party responses) is parsed with a Zod schema before use.

**Compiler settings**

- `NEXTJS-06.1` [MUST] enable `strict`, `noUncheckedIndexedAccess`, `noImplicitOverride`, `exactOptionalPropertyTypes` and `verbatimModuleSyntax` (full `tsconfig.json` in the appendix).
- `NEXTJS-06.2` [MUST] keep `pnpm typecheck` (`tsc --noEmit`) clean in CI.

**Types**

- `NEXTJS-06.3` [MUST NOT] use `any`. Use `unknown` and narrow it, or write the real type.
- `NEXTJS-06.4` [MUST NOT] use non-null assertions (`value!`) or `as` casts to silence the compiler. The exceptions are `as const` and narrowing a value you have just validated.
- `NEXTJS-06.5` [SHOULD] use `type` for unions, props and most shapes, and `interface` only when declaration merging or `extends` chains are needed. Be consistent within a feature.
- `NEXTJS-06.6` [SHOULD] derive types instead of repeating them: `z.infer<typeof schema>`, `ReturnType<typeof fn>`, `Awaited<...>`, `ComponentProps<typeof Button>`.
- `NEXTJS-06.7` [SHOULD] use `satisfies` to check an object against a type while keeping its literal types.
- `NEXTJS-06.8` [SHOULD] model states as discriminated unions (`{ status: 'idle' } | { status: 'error'; message: string }`) rather than several optional flags.
- `NEXTJS-06.9` [MUST] handle every member of a union in a `switch`, ending with an exhaustiveness check.
- `NEXTJS-06.10` [AVOID] `enum`; use a union of string literals or an `as const` object.

**Validation at boundaries**

- `NEXTJS-06.11` [MUST] parse untrusted input with a Zod schema at the boundary: Server Action arguments, Route Handler bodies, `searchParams`, webhook payloads, and responses from external APIs.
- `NEXTJS-06.12` [MUST] validate environment variables once at startup in `src/lib/env.ts` and import them from there, never from `process.env` directly.
- `NEXTJS-06.13` [SHOULD] keep each schema next to the feature that owns it (`features/invoices/schemas.ts`) and export the inferred type beside it.

```ts
// src/features/invoices/schemas.ts
import { z } from 'zod';

export const createInvoiceSchema = z.object({
  customerId: z.uuid(),
  dueDate: z.coerce.date(),
  lines: z
    .array(z.object({ description: z.string().min(1), amountCents: z.number().int().positive() }))
    .min(1, 'An invoice needs at least one line'),
});

export type CreateInvoiceInput = z.infer<typeof createInvoiceSchema>;

// Exhaustiveness check
export function statusLabel(status: InvoiceStatus): string {
  switch (status) {
    case 'draft':
      return 'Draft';
    case 'issued':
      return 'Awaiting payment';
    case 'paid':
      return 'Paid';
    default: {
      const unreachable: never = status;
      throw new Error(`Unknown status: ${String(unreachable)}`);
    }
  }
}
```

## 07. Server and Client Components

Components are Server Components by default, and `'use client'` goes only on the small interactive leaves that need state, effects or browser APIs. The less JavaScript sent to the browser, the faster the page.

**Server Components**

- `NEXTJS-07.1` [SHOULD] fetch data, read cookies and headers, and talk to the database in Server Components, close to where the data is rendered.
- `NEXTJS-07.2` [MUST] mark modules that must never reach the browser (database clients, secrets, server-side SDKs) with `import 'server-only';` at the top, so a mistaken client import fails the build.
- `NEXTJS-07.3` [SHOULD] pass Server Components into Client Components as `children` or props rather than importing them inside client files, which would turn them into client code.

**Client Components**

- `NEXTJS-07.4` [MUST] add `'use client'` only to files that need `useState`, `useReducer`, effects, event handlers, browser APIs, or client-only libraries.
- `NEXTJS-07.5` [SHOULD] push the boundary as far down the tree as possible: make the button interactive, not the whole page.
- `NEXTJS-07.6` [MUST] pass only serializable props from server to client (plain data, Server Action references, JSX). Never pass class instances, functions other than Server Actions, or database records with methods.
- `NEXTJS-07.7` [MUST NOT] put secrets, internal IDs that grant access, or full database rows into client props. Select only the fields the UI shows; everything passed is visible in the page payload.
- `NEXTJS-07.8` [MUST NOT] fetch data in `useEffect` when a Server Component could fetch it.
- `NEXTJS-07.9` [SHOULD] use `next/dynamic` with `ssr: false` only for components that genuinely cannot render on the server (canvas, map widgets).

```tsx
// src/app/(app)/invoices/[invoiceId]/page.tsx (Server Component)
import { notFound } from 'next/navigation';

import { InvoiceActions } from '@/features/invoices/components/invoice-actions';
import { InvoiceSummary } from '@/features/invoices/components/invoice-summary';
import { getInvoice } from '@/features/invoices/queries';
import { requireUser } from '@/lib/auth';

// A loading.tsx beside this page is the Suspense boundary for the awaited params.
export default async function InvoicePage({ params }: PageProps<'/invoices/[invoiceId]'>) {
  const { invoiceId } = await params;
  const user = await requireUser();
  const invoice = await getInvoice(invoiceId, user.id);
  if (!invoice) {
    notFound();
  }

  return (
    <>
      <InvoiceSummary invoice={invoice} />
      {/* Client leaf: receives only what it needs */}
      <InvoiceActions invoiceId={invoice.id} isPaid={invoice.status === 'paid'} />
    </>
  );
}
```

```tsx
// src/features/invoices/components/invoice-actions.tsx
'use client';

import { useTransition } from 'react';

import { Button } from '@/components/ui/button';

import { markInvoicePaid } from '../actions';

export function InvoiceActions({ invoiceId, isPaid }: InvoiceActionsProps) {
  const [isPending, startTransition] = useTransition();

  return (
    <Button
      disabled={isPaid || isPending}
      onClick={() => startTransition(() => markInvoicePaid(invoiceId))}
    >
      {isPending ? 'Saving…' : 'Mark as paid'}
    </Button>
  );
}

type InvoiceActionsProps = { invoiceId: string; isPaid: boolean };
```

## 08. Routing and layouts

URLs are designed first and kept stable; layouts hold shared chrome, pages hold content, and every segment that loads data declares its loading and error states.

**URLs and segments**

- `NEXTJS-08.1` [MUST] use lowercase kebab-case segments that read as nouns: `/invoices/[invoiceId]/edit`, not `/getInvoice?id=`.
- `NEXTJS-08.2` [MUST] `await` `params` and `searchParams` (they are Promises since Next.js 16), and type them with the generated `PageProps<'/route'>` and `LayoutProps<'/route'>` helpers.
- `NEXTJS-08.3` [MUST] validate `searchParams` with a Zod schema before use; they are user input.
- `NEXTJS-08.4` [SHOULD] enable typed routes (`typedRoutes: true`) so `<Link href>` and `router.push` are checked at compile time.
- `NEXTJS-08.5` [SHOULD] implement `generateStaticParams` for dynamic segments whose values are known at build time.

**Layouts**

- `NEXTJS-08.6` [MUST] keep the root layout minimal: `<html>`, `<body>`, fonts, global providers and global styles.
- `NEXTJS-08.7` [SHOULD] use route-group layouts for each distinct shell (marketing site, signed-in app, admin).
- `NEXTJS-08.8` [MUST NOT] rely on a layout to protect its child pages. Layouts do not re-render on every navigation; check authorization in each page, action and handler (section 15).
- `NEXTJS-08.9` [MUST] export `metadata` or `generateMetadata` from every page that is reachable without signing in, with a unique title and description.

**Loading, errors and not found**

- `NEXTJS-08.10` [SHOULD] give every segment that fetches slow data a `loading.tsx`, or wrap the slow part in `<Suspense>` with a skeleton that matches the final layout.
- `NEXTJS-08.11` [MUST] give each top-level section an `error.tsx` (a Client Component with a retry button) and the app a `global-error.tsx`.
- `NEXTJS-08.12` [MUST] call `notFound()` when a requested record does not exist, rather than rendering an empty page.

**Advanced routing**

- `NEXTJS-08.13` [MAY] use parallel routes (`@slot`) and intercepting routes (`(.)photo`) for modals and split views that need their own URLs. Each slot needs a `default.tsx`.
- `NEXTJS-08.14` [AVOID] them for anything a plain component would do; they add files and edge cases.

```tsx
// src/app/(app)/invoices/page.tsx
import { Suspense } from 'react';

import { InvoiceTable } from '@/features/invoices/components/invoice-table';
import { InvoiceTableSkeleton } from '@/features/invoices/components/invoice-table-skeleton';
import { invoiceFilterSchema } from '@/features/invoices/schemas';

export const metadata = { title: 'Invoices' };

// The page shell renders immediately; request data is read inside Suspense.
export default function InvoicesPage({ searchParams }: PageProps<'/invoices'>) {
  return (
    <Suspense fallback={<InvoiceTableSkeleton />}>
      <FilteredInvoices searchParams={searchParams} />
    </Suspense>
  );
}

async function FilteredInvoices({ searchParams }: Pick<PageProps<'/invoices'>, 'searchParams'>) {
  const filter = invoiceFilterSchema.parse(await searchParams);
  return <InvoiceTable filter={filter} />;
}
```

## 09. Data fetching and caching

With Cache Components, everything runs at request time unless it is explicitly marked `'use cache'`. Data is read on the server in feature query functions, cached deliberately with a lifetime and tags, and streamed behind `<Suspense>` when it is dynamic ([caching guide](https://nextjs.org/docs/app/getting-started/caching)).

**Where data is read**

- `NEXTJS-09.1` [MUST] read data in Server Components or in query functions in `features/<feature>/queries.ts`, marked `import 'server-only'`.
- `NEXTJS-09.2` [MUST NOT] call your own Route Handlers from Server Components (`fetch('/api/invoices')`); call the query function directly.
- `NEXTJS-09.3` [SHOULD] start independent requests in parallel (`Promise.all`) rather than awaiting them one after another.
- `NEXTJS-09.4` [SHOULD] wrap request-scoped reads in React `cache()` when several components in one render need the same data (for example the current user).

**Dynamic data and Suspense**

- `NEXTJS-09.5` [MUST] read request-time data (`cookies()`, `headers()`, `params`, `searchParams`, uncached queries) inside a `<Suspense>` boundary or under a `loading.tsx`, so the static shell can be served immediately.
- `NEXTJS-09.6` [SHOULD] place each Suspense boundary around the smallest part that is slow, with a skeleton the same size as the real content.

**Caching**

- `NEXTJS-09.7` [SHOULD] mark a component or query `'use cache'` when its output depends only on its arguments and changes rarely: product catalogs, published content, reference data.
- `NEXTJS-09.8` [MUST] give every `'use cache'` scope a `cacheLife` profile (`'minutes'`, `'hours'`, `'days'`, `'max'` or a named custom profile) and at least one `cacheTag` for the data it depends on.
- `NEXTJS-09.9` [MUST NOT] read `cookies()`, `headers()` or other request data inside a `'use cache'` scope. Read them outside and pass the values in as arguments; arguments become part of the cache key.
- `NEXTJS-09.10` [MUST NOT] cache per-user data in a shared cache. Pass the user ID as an argument, or do not cache it.
- `NEXTJS-09.11` [SHOULD] name tags `<entity>` and `<entity>:<id>` (`invoices`, `invoice:42`) so invalidation is predictable.
- `NEXTJS-09.12` [MAY] use `export const ensureStatic = 'navigation'` on marketing, docs and blog layouts to fail the build if dynamic content sneaks in.
- `NEXTJS-09.13` [MAY] use `await navigation()` or `await prefetch()` from `next/cache` to keep expensive parts out of link prefetches.

**Invalidation**

| Need | API | Where |
| --- | --- | --- |
| User must see their own change immediately | `updateTag('invoice:42')` | Server Actions only |
| Content can update in the background (stale-while-revalidate) | `revalidateTag('invoices', 'max')` | Server Actions, Route Handlers |
| Re-render uncached data on the current page | `refresh()` | Server Actions only |
| Invalidate a whole path | `revalidatePath('/invoices')` | Use sparingly; prefer tags |

```ts
// src/features/invoices/queries.ts
import 'server-only';

import { cacheLife, cacheTag } from 'next/cache';

import { db } from '@/lib/db';

export async function getInvoice(invoiceId: string, userId: string) {
  'use cache';
  cacheLife('hours');
  cacheTag('invoices', `invoice:${invoiceId}`);

  return db.query.invoices.findFirst({ where: (invoice, { and, eq }) => and(eq(invoice.id, invoiceId), eq(invoice.ownerId, userId)) });
}
```

## 10. Mutations: Server Actions and Route Handlers

Server Actions handle mutations from the app's own UI; Route Handlers serve everything else that speaks HTTP: webhooks, third-party callers, file downloads. Both are public endpoints, so every one of them validates input and checks authorization itself.

**Server Actions**

- `NEXTJS-10.1` [MUST] define actions in `features/<feature>/actions.ts` with `'use server'` at the top of the file, not inline inside components.
- `NEXTJS-10.2` [MUST] treat every action as a public POST endpoint: anyone can call it with any arguments. Authenticate, authorize the specific record, and parse arguments with Zod at the start of every action.
- `NEXTJS-10.3` [MUST] return a typed result (`{ ok: true, data } | { ok: false, error, fieldErrors? }`) for expected failures such as validation errors, rather than throwing. Throw only for unexpected failures.
- `NEXTJS-10.4` [MUST] invalidate what changed before returning: `updateTag` for data the user is looking at, `revalidateTag(tag, 'max')` for data others see.
- `NEXTJS-10.5` [SHOULD] call `redirect()` after a successful create when the user should land on the new record. Call it outside `try` / `catch`; it works by throwing.
- `NEXTJS-10.6` [MUST NOT] return more data than the UI needs; the return value is sent to the browser.

**Route Handlers**

- `NEXTJS-10.7` [SHOULD] use Route Handlers only for callers that are not the app's own React UI: webhooks, public APIs, OAuth callbacks, file and feed responses.
- `NEXTJS-10.8` [MUST] verify webhook signatures before reading the payload.
- `NEXTJS-10.9` [MUST] parse request bodies and query strings with Zod and return proper status codes (400 invalid input, 401/403 auth, 404, 409 conflict, 422, 500).
- `NEXTJS-10.10` [MUST NOT] place a `route.ts` and a `page.tsx` in the same segment.

```ts
// src/features/invoices/actions.ts
'use server';

import { updateTag } from 'next/cache';
import { redirect } from 'next/navigation';
import { z } from 'zod';

import { requireUser } from '@/lib/auth';
import { db } from '@/lib/db';

import { createInvoiceSchema } from './schemas';

export type ActionResult = { ok: true } | { ok: false; error: string; fieldErrors?: Record<string, string[]> };

export async function createInvoice(_prev: ActionResult, formData: FormData): Promise<ActionResult> {
  const user = await requireUser();
  const parsed = createInvoiceSchema.safeParse(Object.fromEntries(formData));
  if (!parsed.success) {
    return { ok: false, error: 'Check the highlighted fields', fieldErrors: z.flattenError(parsed.error).fieldErrors };
  }

  const invoice = await db.invoices.create({ ...parsed.data, ownerId: user.id });
  updateTag('invoices');
  redirect(`/invoices/${invoice.id}`);
}

export async function markInvoicePaid(invoiceId: string): Promise<ActionResult> {
  const user = await requireUser();
  const invoice = await db.invoices.findOwned(invoiceId, user.id);
  if (!invoice) {
    return { ok: false, error: 'Invoice not found' };
  }

  await db.invoices.markPaid(invoice.id);
  updateTag(`invoice:${invoice.id}`);
  return { ok: true };
}
```

## 11. Components and styling

Components are small, typed, and composed from design-system primitives; all styling is written in StyleX, which compiles styles to atomic CSS at build time with no runtime cost ([StyleX Next.js guide](https://stylexjs.com/docs/learn/installation/nextjs)).

### Components

- `NEXTJS-11.1` [SHOULD] keep a component under about 150 lines and focused on one job. Split when a component renders unrelated sections or mixes data loading with layout.
- `NEXTJS-11.2` [MUST] type props with a `<Component>Props` type and destructure them in the signature.
- `NEXTJS-11.3` [SHOULD] compose with `children` and slot props rather than adding boolean props for every variant. More than three or four boolean props is a sign the component should split.
- `NEXTJS-11.4` [MUST] give list items a stable `key` from the data, never the array index when the list can reorder.
- `NEXTJS-11.5` [SHOULD] build feature components from `src/components/ui` primitives rather than raw HTML styled from scratch.
- `NEXTJS-11.6` [MUST NOT] define components inside other components; they re-mount on every render.

### StyleX setup

- `NEXTJS-11.7` [MUST] compile StyleX with `@stylexjs/babel-plugin` and `@stylexjs/postcss-plugin`, configured in `babel.config.js` and `postcss.config.js` (appendix). This works with Turbopack from Next.js 16.0.3.
- `NEXTJS-11.8` [MUST] keep a single CSS entry (`src/styles/globals.css`) containing `@stylex;` once, imported from the root layout, with `useCSSLayers: true` so StyleX rules layer predictably over resets.
- `NEXTJS-11.9` [MUST] run `@stylexjs/eslint-plugin` with `valid-styles`, `no-unused`, `valid-shorthands` and `sort-keys`. The compiler accepts invalid styles; the linter is what catches them.
- `NEXTJS-11.10` [SHOULD] know that a `babel.config.js` makes Next.js compile app code with Babel instead of SWC. Builds are slower; this is the accepted cost of StyleX (and the React Compiler uses Babel anyway).

### Writing styles

- `NEXTJS-11.11` [MUST] define styles with `stylex.create` at module level, below the component that uses them, and apply them with `{...stylex.props(...)}`.
- `NEXTJS-11.12` [MUST NOT] use `className` strings, inline `style` objects for static styles, CSS Modules, or another CSS-in-JS library alongside StyleX. Inline `style` is allowed only for truly dynamic values (a measured width), and dynamic StyleX functions are preferred over it.
- `NEXTJS-11.13` [MUST] use design tokens from `stylex.defineVars` for colors, spacing, radii, type sizes, shadows and z-index. Hard-coded hex values and pixel sizes in feature code are not allowed.
- `NEXTJS-11.14` [MUST] put tokens in files named `*.stylex.ts` (for example `src/styles/tokens.stylex.ts`), exporting only `defineVars` / `defineConsts` results; the compiler requires this.
- `NEXTJS-11.15` [SHOULD] create themes (dark mode, brand variants) with `stylex.createTheme` over the same variables, applied at the layout level.
- `NEXTJS-11.16` [SHOULD] write conditional styles per property: `color: { default: tokens.text, ':hover': tokens.textStrong, '@media (prefers-color-scheme: dark)': tokens.textDark }`.
- `NEXTJS-11.17` [SHOULD] let components accept a `style?: stylex.StyleXStyles` prop for caller overrides instead of exposing many visual props, and merge it last: `stylex.props(styles.base, style)`.
- `NEXTJS-11.18` [SHOULD] name style keys by role (`root`, `header`, `label`, `active`), not by appearance (`blueText`, `bigPadding`).

```ts
// src/styles/tokens.stylex.ts
import * as stylex from '@stylexjs/stylex';

export const colors = stylex.defineVars({
  surface: '#FFFFFF',
  text: '#0E1116',
  textMuted: '#5B6476',
  accent: '#3D5AFE',
  danger: '#B4232A',
});

export const space = stylex.defineVars({
  xs: '4px',
  sm: '8px',
  md: '16px',
  lg: '24px',
});
```

```tsx
// src/features/invoices/components/invoice-status-badge.tsx
import * as stylex from '@stylexjs/stylex';

import { colors, space } from '@/styles/tokens.stylex';

export function InvoiceStatusBadge({ status, style }: InvoiceStatusBadgeProps) {
  return (
    <span {...stylex.props(styles.root, status === 'overdue' && styles.overdue, style)}>
      {statusLabel(status)}
    </span>
  );
}

type InvoiceStatusBadgeProps = { status: InvoiceStatus; style?: stylex.StyleXStyles };

const styles = stylex.create({
  root: {
    borderRadius: 999,
    color: colors.textMuted,
    fontSize: 12,
    paddingBlock: space.xs,
    paddingInline: space.sm,
  },
  overdue: {
    color: { default: colors.danger, ':hover': colors.text },
  },
});
```

## 12. State management

Most state is not client state. Data from the database lives on the server and is re-rendered after mutations; state a user should be able to share or bookmark lives in the URL; only short-lived UI state lives in React.

| Kind of state | Where it lives | Example |
| --- | --- | --- |
| **Server data** | Server Components and `'use cache'` queries | The invoice list |
| **Shareable view state** | URL search params | Filters, sort order, page number, selected tab |
| **Form input and pending status** | The form and `useActionState` | Create-invoice form |
| **Local UI state** | `useState` / `useReducer` in the component | An open dropdown, a hover preview |
| **Cross-component UI state** | React context near the components that use it | A multi-step wizard |
| **Global client state** | A small store (Zustand) only when the above do not fit | A long-lived editor canvas |

- `NEXTJS-12.1` [MUST NOT] copy server data into client state and keep it in sync by hand. Mutate with a Server Action and let the server re-render.
- `NEXTJS-12.2` [SHOULD] keep filters, sorting, pagination and selected tabs in the URL, read on the server through `searchParams` and updated on the client with `useRouter` / `<Link>`.
- `NEXTJS-12.3` [SHOULD] use `useOptimistic` for instant feedback on mutations instead of maintaining a parallel client copy of the data.
- `NEXTJS-12.4` [SHOULD] put context providers as deep in the tree as possible, never wrapping the whole app for state one feature uses.
- `NEXTJS-12.5` [AVOID] global client stores. Adding one needs a reason in an ADR.
- `NEXTJS-12.6` [MUST NOT] use `useEffect` to derive state from props or other state; compute it during render.
- `NEXTJS-12.7` [SHOULD] use `useEffectEvent` for non-reactive logic inside effects instead of disabling the exhaustive-deps lint rule.

## 13. Forms

Forms post to Server Actions through the `action` prop, so they work before JavaScript loads, and use `useActionState` to show pending state and errors. Validation runs on the server every time; client checks only make feedback faster.

- `NEXTJS-13.1` [MUST] submit forms with `<form action={serverAction}>` and read the result with `useActionState`, not with `onSubmit` handlers that call `fetch`.
- `NEXTJS-13.2` [MUST] validate on the server with the same Zod schema as the action, even when the client also validates. Client validation is a convenience, never a guarantee.
- `NEXTJS-13.3` [SHOULD] use native HTML constraints (`required`, `type="email"`, `min`, `maxLength`) for instant client feedback.
- `NEXTJS-13.4` [MUST] show field errors next to their fields and link them with `aria-describedby`, and show a form-level error for anything else.
- `NEXTJS-13.5` [MUST] disable the submit button and show a pending label while the action runs (`isPending` from `useActionState`, or `useFormStatus` inside a submit-button component).
- `NEXTJS-13.6` [SHOULD] keep the user's input after a failed submit by returning it from the action or using `defaultValue` from the previous state.
- `NEXTJS-13.7` [SHOULD] use a form library (React Hook Form) only for forms with complex client behavior: dynamic field arrays, multi-step flows, live cross-field validation.

```tsx
// src/features/invoices/components/create-invoice-form.tsx
'use client';

import { useActionState } from 'react';
import * as stylex from '@stylexjs/stylex';

import { Button } from '@/components/ui/button';
import { TextField } from '@/components/ui/text-field';
import { space } from '@/styles/tokens.stylex';

import { createInvoice, type ActionResult } from '../actions';

const initialState: ActionResult = { ok: true };

export function CreateInvoiceForm() {
  const [state, formAction, isPending] = useActionState(createInvoice, initialState);
  const fieldErrors = state.ok ? undefined : state.fieldErrors;

  return (
    <form action={formAction} {...stylex.props(styles.form)}>
      <TextField name="customerId" label="Customer" required errors={fieldErrors?.customerId} />
      <TextField name="dueDate" label="Due date" type="date" required errors={fieldErrors?.dueDate} />
      {!state.ok && <p role="alert">{state.error}</p>}
      <Button type="submit" disabled={isPending}>
        {isPending ? 'Creating…' : 'Create invoice'}
      </Button>
    </form>
  );
}

const styles = stylex.create({
  form: { display: 'grid', gap: space.md, maxWidth: 480 },
});
```

## 14. Errors and loading states

Expected failures are values the UI renders; unexpected ones are thrown and caught by the nearest `error.tsx`. Every place that waits shows a skeleton shaped like the content it is waiting for.

**Errors**

| Situation | How it is handled |
| --- | --- |
| **Record does not exist** | `notFound()` → nearest `not-found.tsx` |
| **User may not see this record** | `notFound()`, so the record's existence is not revealed |
| **Not signed in** | `redirect('/sign-in?next=...')` |
| **Validation failure in an action** | Typed result with field errors (section 10) |
| **Unexpected failure while rendering** | Thrown; nearest `error.tsx` shows a retry |
| **Failure in the root layout** | `global-error.tsx` |

- `NEXTJS-14.1` [MUST] give `error.tsx` a short human message, a retry button (`reset`), and no stack trace or raw error text. The error `digest` may be shown so support can find the log entry.
- `NEXTJS-14.2` [MUST] report unexpected errors to the error tracker (Sentry or equivalent) through `instrumentation.ts` / `onRequestError`, not by adding `try` / `catch` to every component.
- `NEXTJS-14.3` [MUST NOT] catch an error only to render nothing. Either handle it with a visible state or let it reach the boundary.
- `NEXTJS-14.4` [MUST] keep error messages shown to users free of internal details: table names, IDs of other users, stack traces.

**Loading**

- `NEXTJS-14.5` [SHOULD] use skeletons that match the final layout's size, so the page does not jump when data arrives.
- `NEXTJS-14.6` [SHOULD] stream: render the shell immediately and suspend only the parts that wait (section 9).
- `NEXTJS-14.7` [MUST] show pending state on every button that triggers an action, and prevent double submission.
- `NEXTJS-14.8` [AVOID] full-page spinners; they hide content that is already available.

## 15. Security

Authorization is checked where data is read and written, not only at the edge. `proxy.ts` handles coarse redirects; a data-access layer re-checks the user on every query and action, because pages, actions and handlers can all be reached directly.

**Authentication and authorization**

- `NEXTJS-15.1` [MUST] route every database read and write through a data-access function that takes the current user and enforces ownership or role (`findOwned(invoiceId, user.id)`).
- `NEXTJS-15.2` [MUST] call `requireUser()` (or equivalent) at the top of every Server Action, Route Handler and page that shows private data. A check in a layout or in `proxy.ts` alone is not enough.
- `NEXTJS-15.3` [SHOULD] use `proxy.ts` only for cheap, coarse decisions: redirect signed-out users away from app routes, set headers, rewrite locales. It must not query the database for authorization.
- `NEXTJS-15.4` [MUST] use an established auth library (Auth.js, Better Auth, Clerk or the platform's identity provider) and never hand-roll session cookies or password hashing.
- `NEXTJS-15.5` [MUST] set session cookies `HttpOnly`, `Secure` and `SameSite=Lax` (or `Strict`).

**Secrets and environment variables**

- `NEXTJS-15.6` [MUST] keep secrets in server-only variables (no `NEXT_PUBLIC_` prefix). Anything prefixed `NEXT_PUBLIC_` is inlined into the browser bundle.
- `NEXTJS-15.7` [MUST] import environment variables only from `src/lib/env.ts`, which validates them with Zod and is marked `server-only` (with a separate validated module for public variables).
- `NEXTJS-15.8` [MUST] list every variable in `.env.example` with a description and no real value. [MUST NOT] commit `.env` files with real values.

**Data exposure**

- `NEXTJS-15.9` [MUST] select only the fields a component renders before passing data to a Client Component or returning it from an action. Use a data-transfer type, never the raw database row.
- `NEXTJS-15.10` [SHOULD] use React's taint APIs (`experimental_taintObjectReference`) only if enabled for the project; otherwise rely on explicit field selection.

**Headers and input**

- `NEXTJS-15.11` [MUST] send a Content Security Policy with nonces, plus `Strict-Transport-Security`, `X-Content-Type-Options: nosniff`, `Referrer-Policy` and a restrictive `Permissions-Policy`.
- `NEXTJS-15.12` [MUST NOT] use `dangerouslySetInnerHTML` except with content sanitized by a vetted library (DOMPurify), with a comment.
- `NEXTJS-15.13` [MUST] configure `images.remotePatterns` narrowly (exact hosts and paths), never a wildcard host.
- `NEXTJS-15.14` [MUST] rate-limit sign-in, sign-up, password reset and any action that sends email or costs money.
- `NEXTJS-15.15` [SHOULD] apply Next.js security releases within days of publication and subscribe to the Next.js security advisories.

## 16. Performance

Performance is judged by Core Web Vitals measured on real users, and most of it comes from sending less JavaScript and streaming early. Measure before optimizing, and record the before and after numbers in the commit.

| Metric | Target (75th percentile) |
| --- | --- |
| **Largest Contentful Paint (LCP)** | under 2.5 s |
| **Interaction to Next Paint (INP)** | under 200 ms |
| **Cumulative Layout Shift (CLS)** | under 0.1 |

**JavaScript**

- `NEXTJS-16.1` [SHOULD] keep `'use client'` boundaries at the leaves (section 7); every client file adds to the bundle.
- `NEXTJS-16.2` [SHOULD] load heavy, below-the-fold or interaction-only components with `next/dynamic` or a dynamic `import()` on demand.
- `NEXTJS-16.3` [MUST] check the bundle analyzer before adding a dependency over about 20 kB gzipped, and prefer a lighter one or a platform API.
- `NEXTJS-16.4` [MUST NOT] import a whole library for one function (`lodash`, icon packs); import the specific module.
- `NEXTJS-16.5` [SHOULD] rely on the React Compiler for memoization and remove manual `useMemo` / `useCallback` unless a profile shows a gain.

**Images, fonts and scripts**

- `NEXTJS-16.6` [MUST] render images with `next/image`, with explicit `width` and `height` (or `fill` with a sized parent) and a meaningful `sizes` value for responsive images.
- `NEXTJS-16.7` [SHOULD] set `priority` (or `preload`) only on the image that is the LCP element of the page.
- `NEXTJS-16.8` [MUST] load fonts with `next/font`, self-hosted, with `display: 'swap'`, and only the weights in use.
- `NEXTJS-16.9` [MUST] load third-party scripts with `next/script` and the latest-loading strategy that works (`lazyOnload` for analytics and chat widgets).

**Rendering**

- `NEXTJS-16.10` [SHOULD] make every page with no per-user content fully static or cached, and keep request-time rendering to the parts that need it.
- `NEXTJS-16.11` [SHOULD] use `<Link>` for internal navigation so routes are prefetched, and `prefetch` explicitly on links users are likely to click next.
- `NEXTJS-16.12` [MUST] reserve space for anything that loads late (images, embeds, ads, skeletons) so the layout does not shift.

## 17. Accessibility

Every page meets WCAG 2.2 level AA. Semantic HTML does most of the work; ARIA fills the gaps only where no native element fits.

- `NEXTJS-17.1` [MUST] use the native element for the job: `<button>` for actions, `<a>` / `<Link>` for navigation, `<label>` for every form control, headings in order (one `<h1>` per page).
- `NEXTJS-17.2` [MUST NOT] attach click handlers to `<div>` or `<span>`. If something is clickable, it is a button or a link.
- `NEXTJS-17.3` [MUST] give every image meaningful `alt` text, or `alt=""` when it is decorative.
- `NEXTJS-17.4` [MUST] make every interaction work with the keyboard alone, with a visible focus style (never `outline: none` without a replacement).
- `NEXTJS-17.5` [MUST] meet contrast ratios of 4.5:1 for body text and 3:1 for large text and UI components. Check the design tokens once, and every use inherits it.
- `NEXTJS-17.6` [MUST] announce form errors and async results: link field errors with `aria-describedby`, and use `role="alert"` or an `aria-live` region for messages that appear after submit.
- `NEXTJS-17.7` [MUST] build dialogs, menus, comboboxes and tabs from an accessible primitive library (Radix, React Aria, Base UI) or the native `<dialog>`, not from scratch.
- `NEXTJS-17.8` [SHOULD] respect `prefers-reduced-motion` for animations and view transitions.
- `NEXTJS-17.9` [MUST] set `lang` on `<html>` and a unique, descriptive `<title>` per page through metadata.
- `NEXTJS-17.10` [SHOULD] move focus to the main heading or content after client navigations that replace the page, and to the first error after a failed submit.
- `NEXTJS-17.11` [MUST] run automated checks (axe through Playwright) in CI, and test new flows with a screen reader before release.

## 18. Testing

Unit and component tests run with Vitest and Testing Library next to the code they test; user flows run end to end in Playwright against a production build. Async Server Components are covered by end-to-end tests, because unit test runners cannot render them.

| Layer | Tool | Lives in | Covers |
| --- | --- | --- | --- |
| **Unit** | Vitest | `<name>.test.ts` beside the source | Schemas, formatters, pure logic, query and action functions with fakes |
| **Component** | Vitest + Testing Library (jsdom) | `<name>.test.tsx` beside the component | Client Components and synchronous Server Components |
| **End to end** | Playwright | `e2e/` | Pages, async Server Components, forms, auth flows, accessibility (axe) |

**Rules**

- `NEXTJS-18.1` [MUST] put each unit or component test next to the file it tests, named `<name>.test.ts(x)`.
- `NEXTJS-18.2` [MUST] query the DOM the way a user would: by role, label and text (`getByRole('button', { name: 'Create invoice' })`), not by test IDs or class names. Use `data-testid` only when nothing else identifies the element.
- `NEXTJS-18.3` [MUST] test Server Actions as functions: call them with a `FormData` and assert on the result and the data-layer fake, including the unauthorized and invalid-input cases.
- `NEXTJS-18.4` [SHOULD] cover each critical user flow (sign in, create, edit, pay, delete) with one Playwright test that runs against `next build && next start`.
- `NEXTJS-18.5` [MUST] keep tests deterministic: fixed clock, seeded data, no real third-party calls (mock them with MSW at the network layer).
- `NEXTJS-18.6` [MUST] add a failing test that reproduces a bug before fixing it.
- `NEXTJS-18.7` [SHOULD] run Playwright's axe check on every page covered by an end-to-end test.
- `NEXTJS-18.8` [AVOID] snapshot tests of whole components; they break on every change and are approved without reading.

```tsx
// src/features/invoices/components/invoice-status-badge.test.tsx
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';

import { InvoiceStatusBadge } from './invoice-status-badge';

describe('InvoiceStatusBadge', () => {
  it('labels an issued invoice as awaiting payment', () => {
    render(<InvoiceStatusBadge status="issued" />);

    expect(screen.getByText('Awaiting payment')).toBeVisible();
  });
});
```

```ts
// e2e/create-invoice.spec.ts
import AxeBuilder from '@axe-core/playwright';
import { expect, test } from '@playwright/test';

test('creates an invoice and lands on its page', async ({ page }) => {
  await page.goto('/invoices/new');
  await page.getByLabel('Customer').fill('7f1c2b9e-1a2b-4c3d-9e8f-0a1b2c3d4e5f');
  await page.getByLabel('Due date').fill('2026-11-02');
  await page.getByRole('button', { name: 'Create invoice' }).click();

  await expect(page).toHaveURL(/\/invoices\/[\w-]+$/);
  const results = await new AxeBuilder({ page }).analyze();
  expect(results.violations).toEqual([]);
});
```

## 19. Build, configuration and deployment

One set of `package.json` scripts runs the same checks locally and in CI, configuration lives in typed files, and every environment is built from the same commit with only environment variables changing.

**Scripts**

| Script | Runs | In CI |
| --- | --- | --- |
| **`pnpm dev`** | `next dev` (Turbopack) | No |
| **`pnpm build`** | `next build` | Yes |
| **`pnpm lint`** | `eslint . --max-warnings 0` | Yes |
| **`pnpm format:check`** | `prettier --check .` | Yes |
| **`pnpm typecheck`** | `tsc --noEmit` | Yes |
| **`pnpm test`** | `vitest run` | Yes |
| **`pnpm e2e`** | `playwright test` against `next start` | Yes, on main and release branches |

**Rules**

- `NEXTJS-19.1` [MUST] write the Next.js config as `next.config.ts` typed with `NextConfig`, and keep it small: every option has a comment saying why it is there.
- `NEXTJS-19.2` [MUST] run lint, format check, typecheck, unit tests and build on every pull request; `next build` no longer lints for you.
- `NEXTJS-19.3` [MUST] configure Vitest with the StyleX Babel plugin so components that use StyleX render in tests.
- `NEXTJS-19.4` [MUST] keep all configuration that differs per environment in environment variables, validated at startup (section 15). Removed options like `publicRuntimeConfig` are not used.
- `NEXTJS-19.5` [SHOULD] use `output: 'standalone'` when deploying in a container, with a multi-stage Dockerfile that copies only `.next/standalone`, `.next/static` and `public`.
- `NEXTJS-19.6` [SHOULD] configure a shared cache handler (Redis or the platform's) for `'use cache'` when running more than one instance, so instances do not serve different cached data.
- `NEXTJS-19.7` [MUST] give every deployment a health check route (`/api/health`) that checks dependencies without exposing details.
- `NEXTJS-19.8` [SHOULD] let Renovate or Dependabot propose dependency updates weekly, with Next.js and React updated together.
- `NEXTJS-19.9` [MUST] keep `experimental` flags out of production config unless an ADR names the flag and the plan to remove it.

## 20. Comments and documentation

Comments explain why, the code explains what. Exported functions, hooks and shared components get a short TSDoc comment; everything else is documented by good names and types.

- `NEXTJS-20.1` [SHOULD] give every exported function, hook, Server Action and `components/ui` primitive a `/** */` TSDoc comment starting with a verb phrase: "Returns the invoices a user can see."
- `NEXTJS-20.2` [SHOULD] document what callers need: required permissions, what happens when nothing is found, caching behavior (`cacheLife`, tags), and side effects (emails sent, cache invalidated).
- `NEXTJS-20.3` [MUST NOT] repeat the type signature in prose (`@param id - the id`). Document a parameter only when its meaning or constraints are not obvious from its name and type.
- `NEXTJS-20.4` [MUST] write implementation comments with `//` and explain why when the code does something surprising: a workaround for a framework bug (with a link), an ordering requirement, a performance trick.
- `NEXTJS-20.5` [MUST] write TODOs as `// TODO: <issue link> - <what needs doing>`.
- `NEXTJS-20.6` [AVOID] commented-out code. Git remembers it.
- `NEXTJS-20.7` [MUST] keep `README.md` current with how to install, run, test and deploy, and the list of environment variables (pointing at `.env.example`).
- `NEXTJS-20.8` [SHOULD] record significant decisions (auth library, database, caching strategy, a global store) as short ADRs in `docs/adr/NNNN-title.md`.

```ts
/**
 * Returns the invoice if `userId` owns it, or `null`.
 *
 * Cached for hours, keyed by both arguments and tagged `invoice:<id>`;
 * `markInvoicePaid` and `updateInvoice` invalidate it with `updateTag`.
 */
export async function getInvoice(invoiceId: string, userId: string): Promise<InvoiceDetail | null> {
  // ...
}
```

## 21. Git and review

Commits are small, focused and written in Conventional Commits form. One commit does one thing, passes CI on its own, and can be reverted on its own.

```text
<type>(<scope>): <imperative summary, under 72 characters>

<optional body: why the change was needed, what it trades off>
```

| Type | Use for | Example |
| --- | --- | --- |
| **`feat`** | New capability | `feat(invoices): add PDF download` |
| **`fix`** | Bug fix | `fix(auth): redirect to original page after sign-in` |
| **`perf`** | Measured speedup | `perf(invoices): cache list query, LCP 3.1 s to 1.4 s` |
| **`refactor`** | No behavior change | `refactor(invoices): move queries out of page files` |
| **`test`** | Tests only | `test(invoices): cover unauthorized markInvoicePaid` |
| **`build`** | Config, dependencies, CI | `build: upgrade next to 16.4` |
| **`style`** | Formatter runs, renames with no logic change | `style: apply prettier` |
| **`docs`** | Docs and ADRs | `docs(adr): choose Better Auth` |
| **`chore`** | Everything else | `chore: update .env.example` |

- `NEXTJS-21.1` [MUST] use the feature folder as the scope (`invoices`, `auth`, `billing`).
- `NEXTJS-21.2` [MUST] keep formatting-only and rename-only changes in their own commits.
- `NEXTJS-21.3` [SHOULD] mark breaking changes with `!` and a `BREAKING CHANGE:` footer.

**Review checklist**

- [ ] CI is green: lint with zero warnings, format, typecheck, tests, build
- [ ] `'use client'` only on interactive leaves; no server-only code reachable from the client
- [ ] Every action, handler and private page authenticates and authorizes itself
- [ ] All untrusted input parsed with Zod; no `any`, no unchecked casts
- [ ] Client props and action results contain only the fields the UI needs
- [ ] Request data read inside Suspense; `'use cache'` scopes have `cacheLife` and tags
- [ ] Mutations invalidate the right tags
- [ ] Styles use StyleX tokens; no hard-coded colors or spacing
- [ ] Keyboard, labels, alt text and contrast checked
- [ ] New behavior has tests; a bug fix has a regression test
- [ ] Commit messages are Conventional Commits

## Appendix

Drop-in files for a new project. Install the packages they reference with pnpm, and adjust names to your own.

### A. next.config.ts

```ts
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  // Cache Components programming model (recommended for every app as of 16.4).
  cacheComponents: true,
  partialPrefetching: true,
  // Automatic memoization; replaces most manual useMemo/useCallback.
  reactCompiler: true,
  // Type-checked <Link href> and router.push.
  typedRoutes: true,
  // Container deploys copy only .next/standalone, .next/static and public.
  output: 'standalone',
  images: {
    remotePatterns: [{ protocol: 'https', hostname: 'images.example.com', pathname: '/uploads/**' }],
  },
};

export default nextConfig;
```

### B. tsconfig.json

```json
{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["dom", "dom.iterable", "esnext"],
    "module": "esnext",
    "moduleResolution": "bundler",
    "jsx": "react-jsx",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true,
    "exactOptionalPropertyTypes": true,
    "verbatimModuleSyntax": true,
    "isolatedModules": true,
    "resolveJsonModule": true,
    "skipLibCheck": true,
    "noEmit": true,
    "incremental": true,
    "plugins": [{ "name": "next" }],
    "paths": { "@/*": ["./src/*"] }
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
  "exclude": ["node_modules"]
}
```

### C. eslint.config.mjs

```js
import stylex from '@stylexjs/eslint-plugin';
import nextVitals from 'eslint-config-next/core-web-vitals';
import nextTs from 'eslint-config-next/typescript';
import { defineConfig, globalIgnores } from 'eslint/config';

export default defineConfig([
  ...nextVitals,
  ...nextTs,
  {
    plugins: { '@stylexjs': stylex },
    rules: {
      '@stylexjs/valid-styles': 'error',
      '@stylexjs/no-unused': 'error',
      '@stylexjs/valid-shorthands': 'warn',
      '@stylexjs/sort-keys': 'warn',
      '@typescript-eslint/no-explicit-any': 'error',
      '@typescript-eslint/consistent-type-imports': 'error',
      'import/no-default-export': 'error',
    },
  },
  {
    // Next.js requires default exports from route and config files.
    files: ['src/app/**/*.tsx', 'src/app/**/*.ts', '*.config.{ts,mjs,js}', 'src/proxy.ts'],
    rules: { 'import/no-default-export': 'off' },
  },
  globalIgnores(['.next/**', 'out/**', 'build/**', 'next-env.d.ts']),
]);
```

### D. prettier.config.mjs

```js
/** @type {import('prettier').Config} */
export default {
  printWidth: 100,
  singleQuote: true,
  jsxSingleQuote: false,
  semi: true,
  trailingComma: 'all',
  plugins: ['@ianvs/prettier-plugin-sort-imports'],
  importOrder: ['<BUILTIN_MODULES>', '^(react|next)(/.*)?$', '<THIRD_PARTY_MODULES>', '', '^@/(.*)$', '', '^[./]'],
};
```

### E. StyleX: babel.config.js, postcss.config.js, globals.css

From the [StyleX Next.js guide](https://stylexjs.com/docs/learn/installation/nextjs), adjusted for the `src/` layout.

```js
// babel.config.js
const path = require('path');
const dev = process.env.NODE_ENV !== 'production';

module.exports = {
  presets: ['next/babel'],
  plugins: [
    [
      '@stylexjs/babel-plugin',
      {
        dev,
        runtimeInjection: false,
        enableInlinedConditionalMerge: true,
        treeshakeCompensation: true,
        aliases: { '@/*': [path.join(__dirname, 'src', '*')] },
        unstable_moduleResolution: { type: 'commonJS' },
      },
    ],
  ],
};
```

```js
// postcss.config.js
const babelConfig = require('./babel.config');

module.exports = {
  plugins: {
    '@stylexjs/postcss-plugin': {
      include: ['src/**/*.{js,jsx,ts,tsx}'],
      babelConfig: {
        babelrc: false,
        parserOpts: { plugins: ['typescript', 'jsx'] },
        plugins: babelConfig.plugins,
      },
      useCSSLayers: true,
    },
    autoprefixer: {},
  },
};
```

```css
/* src/styles/globals.css, imported once from src/app/layout.tsx */
@layer resets {
  *,
  *::before,
  *::after {
    box-sizing: border-box;
    margin: 0;
  }
}

@stylex;
```

### F. src/lib/env.ts

```ts
import 'server-only';

import { z } from 'zod';

const serverEnvSchema = z.object({
  DATABASE_URL: z.url(),
  AUTH_SECRET: z.string().min(32),
  NODE_ENV: z.enum(['development', 'test', 'production']),
});

export const env = serverEnvSchema.parse(process.env);
```

### G. src/proxy.ts

```ts
import { NextResponse, type NextRequest } from 'next/server';

// Coarse redirect only. Every page, action and handler still checks the user itself.
export function proxy(request: NextRequest) {
  const hasSession = request.cookies.has('session');
  if (!hasSession) {
    const signIn = new URL('/sign-in', request.url);
    signIn.searchParams.set('next', request.nextUrl.pathname);
    return NextResponse.redirect(signIn);
  }
  return NextResponse.next();
}

export const config = {
  matcher: ['/invoices/:path*', '/settings/:path*'],
};
```

### H. Starting a new project

1. Run `pnpm create next-app@latest` with TypeScript, ESLint, the App Router and `src/`; decline Tailwind.
2. Copy the files above, install StyleX (`@stylexjs/stylex`, `@stylexjs/babel-plugin`, `@stylexjs/postcss-plugin`, `@stylexjs/eslint-plugin`), Zod, Vitest, Testing Library and Playwright.
3. Pin Node.js in `.nvmrc` and `engines`, and pnpm in `packageManager`.
4. Create `src/styles/tokens.stylex.ts` with the design tokens and `src/lib/env.ts` with the variables, and fill in `.env.example`.
5. Add the scripts from section 19 and a CI workflow that runs them on every pull request.
6. Write `docs/adr/0001-project-scope.md` saying what the app is and is not.
