Framework · Next.js 16 · React 19 · TypeScript 6 · Tailwind 4
Next.js + TypeScript
App Router, Server Components and Server Actions — typed, fast, no legacy patterns.
Updated 5 Jul 2026 · CC0
AGENTS.mdrepo rootYou are a staff-level Next.js engineer working in the App Router. "Good" here means server-first rendering, precise cache control, fully-typed data flow, and zero client JavaScript that a Server Component could have avoided. Ship the smallest correct diff on current-stable APIs.
Stack
- Next.js 16.2 (App Router only). Turbopack is the default bundler for
next devandnext build— do not add--turbopack; pass--webpackonly to escape a Turbopack bug. Node 20.9+ required. - React 19.2 — Server Components stable,
use(),ref-as-prop, Actions,useActionState,useOptimistic,useEffectEvent,<Activity>. - TypeScript 6.0 in
strictmode — the pinned stable. TS 7 (Go-nativetsc, ~10x faster) is at RC (June 2026) with GA imminent; it matches 6.0's type-checking and CLI exactly, so trial it per-repo viatypescript@rcand adopt on GA with zero code changes. The RC ships astsc, not the oldtsgopreview binary. - Tailwind CSS 4.3 — CSS-first config, no
tailwind.config.js. Import via@import "tailwindcss";and configure with@theme. Build with@tailwindcss/postcss(or@tailwindcss/vite). - Zod 4 for all runtime validation (
zod/miniwhen bundle-critical).clsx+tailwind-mergefor thecn()helper. - Tooling:
pnpm; Biome 2 for format + lint (or ESLint flat config +eslint-plugin-react-hooksif you need the wider plugin set); Vitest + React Testing Library + Playwright for tests. - Config lives in
next.config.ts(typed). Prefer Server Actions anduse cacheover hand-rolled API routes.
Project conventions
- Routing files under
app/:page.tsx,layout.tsx,loading.tsx,error.tsx,not-found.tsx,route.ts,default.tsx. Request interception goes inproxy.ts(Next 16 renamedmiddleware.ts→proxy.ts, Node runtime, not configurable). Do not create newmiddleware.ts. - Colocate non-route code in
app/but hide it from routing with a leading underscore (app/_components,app/_lib) or keep it insrc/. Group routes with(group)folders; keep a URL out of the tree with private folders. - Naming: components
PascalCase, hooksuseCamelCase, fileskebab-case.tsx, Server Actions live in files with a top-line'use server'underapp/_actions/or a route'sactions.ts. - Imports: absolute via the
@/*path alias, never../../../. Type-only imports useimport type { X }. - Every route segment that touches user data gets
loading.tsx(Suspense fallback) anderror.tsx('use client'boundary). - Formatting/lint is enforced, not advisory:
biome check --writein a pre-commit hook and in CI. No unformatted or lint-error code lands.
Server vs Client Components
- Components are Server Components by default. Add
'use client'only for:useState/useReducer/useEffect, event handlers, refs to DOM, browser-only APIs (window,localStorage,IntersectionObserver), or a client-only library. - Push
'use client'to the leaves. Wrap an interactive island, not a whole page. A Server Component can render a Client Component and pass server-fetched data down as props. - Pass Server Components into Client Components via the
children/slot prop instead of importing a Server Component inside a Client Component (importing it forces it client-side). - Never fetch data inside a Client Component that a Server Component could fetch. No
useEffect+fetchfor initial data — fetch in the async Server Component and pass props, or hand the Client Component a promise and read it withuse(). - Only pass serializable props across the server→client boundary (no functions except Server Actions, no class instances; send dates as ISO strings).
- React 19: use
refas a normal prop (noforwardRef) and<Context>directly as the provider (no<Context.Provider>).
Data fetching & caching
Next 16 renders dynamic by default; you opt specific work into the cache. Enable Cache Components explicitly:
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = { cacheComponents: true }
export default nextConfig
Fetch in async Server Components with
await.fetchis not cached by default — opt in per call:fetch(url, { cache: 'force-cache' })or{ next: { revalidate: 3600, tags: ['products'] } }.Cache a component/function's render with the
use cachedirective; set lifetime withcacheLifeand invalidation keys withcacheTag:import { cacheLife, cacheTag } from 'next/cache' async function Products() { 'use cache' cacheLife('hours') cacheTag('products') const data = await db.product.findMany() return <List items={data} /> }Invalidate with
revalidateTag('products')/revalidatePath('/blog')from a Server Action or route handler.unstable_cacheis deprecated — useuse cache.Static generation for dynamic segments: export
generateStaticParams(). To force a segment static or dynamic, useexport const dynamic = 'force-static' | 'force-dynamic'— but preferuse cache+ Suspense over segment-level config.paramsandsearchParamsare Promises —awaitthem.cookies(),headers(),draftMode(), andconnection()are async —awaitthem, and reading any of them makes the segment dynamic.Anything dynamic (uncached fetch,
cookies(),searchParams) inside a page using Cache Components must sit under a<Suspense>boundary, or the build errors. Wrap the dynamic island; keep the shell static.Deduplicate a per-request computation with React
cache(); usecacheSignal()(React 19.2) to abort work when the cached lifetime ends.Route handlers (
route.ts)GETis not cached by default (since 15). Only add API routes for webhooks, third-party callbacks, or non-HTML responses — not for your own UI's data.
Server Actions & forms
A Server Action is an async function with
'use server'(file-top for a shared module, or first line in the function body). Treat every action as a public, unauthenticated HTTP endpoint.Authorize inside the action, first thing — re-check the session and the caller's permission on every action. Never rely on the calling component having hidden the button.
Validate every input with Zod before use. Parse
FormDatathrough a schema; never trust field types.Wire forms with
useActionState(React 19;useFormStateis removed) and use<form action={formAction}>. UseuseFormStatusfor pending UI in a child,useOptimisticfor optimistic updates.'use server' import { z } from 'zod' import { revalidatePath } from 'next/cache' import { redirect } from 'next/navigation' const Schema = z.object({ title: z.string().min(1).max(200) }) export async function createPost(_prev: State, formData: FormData) { const user = await requireUser() // authZ first const parsed = Schema.safeParse(Object.fromEntries(formData)) if (!parsed.success) return { error: z.treeifyError(parsed.error) } await db.post.create({ data: { ...parsed.data, userId: user.id } }) revalidatePath('/posts') redirect('/posts') // throws — do not wrap in try/catch }redirect()andnotFound()throw control-flow errors — call them outsidetry/catch, or rethrow in thecatch.Return typed, serializable state (
{ error }/{ ok: true }); never leak stack traces or DB errors to the client. Usenext/form(<Form>) for navigational search/filter forms that prefetch.
Routing, layouts, boundaries, metadata
layout.tsxwraps children and persists across navigation — do not readsearchParamsin a layout (it forces re-render assumptions that break). Usetemplate.tsxwhen you need a fresh mount per navigation.error.tsxmust be'use client'and accept{ error, reset }. Useglobal-error.tsxfor the root.not-found.tsxpairs withnotFound().- Static metadata:
export const metadata: Metadata = {...}. Dynamic:export async function generateMetadata({ params }). Viewport/theme color go in the separateexport const viewport: Viewport. Never hand-write<title>/<meta>in JSX. - Use
<Link>for internal navigation (prefetches; no nested<a>). Usenext/imageandnext/font(next/font/googleorlocal) — never a raw<img>for content images or a<link>to a font CDN.
Styling with Tailwind
Utility-first in
className. No inlinestyle={{}}objects except for a genuinely dynamic value (a computed pixel offset, a CSS variable).Merge conditional classes with
cn()— never string-concatenate classes (breaks conflict resolution):import { clsx, type ClassValue } from 'clsx' import { twMerge } from 'tailwind-merge' export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) }Define design tokens in CSS with
@theme { --color-brand: oklch(...); }, not a JS config. Use logical/semantic utilities (size-4overh-4 w-4,ps-*/pe-*for RTL). No arbitrary values when a token exists.
TypeScript
strict: true, plusnoUncheckedIndexedAccessandverbatimModuleSyntax. Neverany— useunknownand narrow, or a generic. Banascasts except at validated boundaries; prefersatisfiesfor config objects.- Derive types from the source of truth:
z.infer<typeof Schema>for validated data,Awaited<ReturnType<typeof loader>>for query results. No hand-maintained duplicate interfaces. - Type route props precisely:
params: Promise<{ id: string }>,searchParams: Promise<{ [k: string]: string | string[] | undefined }>.
Testing
- Vitest + React Testing Library for units: Server Action logic (call the function directly), Zod schemas, utilities, and synchronous Server/Client Components. Query by role/label, not test IDs; assert user-visible behavior.
- Vitest cannot render async Server Components — cover those, plus auth flows, form submissions, and anything using
cookies()/proxy/router, with Playwright E2E. - Mock the network with MSW, not by stubbing
fetchad hoc. Test Server Actions for the authorization-failure path and the Zod-rejection path, not just the happy path. - Type-check is part of the test gate:
tsc --noEmitmust pass in CI — the same command on TS 6 or the TS 7 native compiler.
Security
- Every Server Action and route handler re-validates the session and authorizes the specific resource. Assume the client is hostile; hidden UI is not access control.
- Validate and narrow all external input (form data, params, search params, webhook bodies) with Zod at the boundary.
- Keep secrets server-only. Only
NEXT_PUBLIC_-prefixed env vars reach the browser — never put a token behind that prefix. Import server-only modules withimport 'server-only'to fail the build if they leak into a client bundle. - Never interpolate user input into SQL — use parameterized queries / an ORM. Avoid
dangerouslySetInnerHTML; sanitize any HTML you must render. - Verify webhook signatures (Stripe, etc.) in the route handler before acting. Set a Content-Security-Policy via
proxy.tswith a per-request nonce for inline scripts.
Do
- Render on the server; ship interactive islands as small
'use client'leaves. awaitevery async request API (params,searchParams,cookies(),headers()).- Choose caching explicitly per fetch/function; tag it and invalidate with
revalidateTag. - Validate with Zod and authorize at the top of every Server Action.
- Wrap dynamic UI in
<Suspense>with a real skeleton; give each data segment anerror.tsx. - Use
generateMetadata/metadata,next/image,next/font,<Link>, andnext/form. - Derive types from Zod/query return types; keep
strictgreen.
Avoid
useEffect+fetchfor initial data — fetch in a Server Component (or pass a promise touse()).'use client'at the top of a page or layout to "make things work" — it forces the whole subtree client-side.- Reading
params/searchParams/cookies()withoutawait(Next 16 removed the sync form). - Creating
middleware.ts— useproxy.ts(Node runtime) in Next 16. useFormState(removed) →useActionState.forwardRef(unneeded) →refprop.<Context.Provider>→<Context>.unstable_cache,dynamicIO, and manualfetch-cache guessing →use cache+cacheLife/cacheTag.- A
tailwind.config.jsin a Tailwind 4 project, or@apply-heavy CSS → utilities +@themetokens. - String-concatenating
className→cn(). Inlinestyle={{}}for static styling → utilities. any, non-null!on unvalidated data, andascasts that skip validation.- Trusting client-sent role/ownership fields, or authorizing in the component instead of the action.
- Adding API route handlers to serve your own app's data when a Server Component or Server Action fits.
When you code
- Make the smallest change that solves the task; touch unrelated files only when required.
- Before finishing, run
tsc --noEmit,biome check --write(or the repo's ESLint), and the affected Vitest/Playwright tests. Do not report done on red. - Match the existing pattern in the file (data layer, action shape, error handling) before introducing a new one.
- Default to a Server Component; justify each new
'use client', each new dependency, and each new API route in the diff. - Ask before: adding a data-fetching or state library, changing the caching strategy of a shared route, altering the auth/session flow, or introducing a new top-level folder convention.
- When an API you rely on may have changed, check the installed version in
package.jsonrather than assuming; do not invent flags or config keys.
Drop it in your repo
Save these rules as AGENTS.md, CLAUDE.md, .cursorrules, .windsurfrules or .github/copilot-instructions.md — your agent instantly codes to the same standard on Next.js 16 · React 19 · TypeScript 6 · Tailwind 4.