Next.js App Router Conventions
Agent Quick Rules {#agent-quick-rules}
- Default to Server Components;
'use client'MUST have an explanatory comment. page.tsxMUST be a thin shell; logic lives infeatures/{feature}/.- MUST NOT import across
features/{a}/andfeatures/{b}/. proxy.tsMUST only do optimistic cookie checks; authoritative auth in Server Components/Actions.- MUST await
params,searchParams,cookies,headers. route.tsMUST NOT be used except webhooks, OAuth callbacks, or file/binary responses.- ALL environment variable access MUST go through
lib/env.ts. MUST NOT useprocess.env.Xdirectly. Seedocs/conventions/frontend/environment-and-runtime-config.md. - Stay on the latest Next.js 16.x and React 19.x patch releases. RSC and middleware advisories (May 2026) require
next@16.2.6+and matchingreact@19.2.6.
1. Guiding Philosophy
The App Router is a full-stack rendering framework, not a client-side router. Its fundamental split between server components and client components maps directly to the read/write separation in the backend architecture. Server components are the read path: they fetch data, render HTML, and send it to the browser with zero JavaScript. Client components are the write path: they handle user interactions, manage local state, and trigger mutations. This split is enforced by the framework and MUST be respected, not worked around.
Every architectural decision in this guide follows from that split. A component that fetches data and renders it statically is a server component. A component that responds to a button click is a client component. A page that does both passes data from a server component parent to a client component child. The boundary is explicit, deliberate, and documented with a comment on every "use client" directive.
Full convention: docs/conventions/frontend/nextjs-app-router.md
When generating new files: Load and copy from docs/blueprints/frontend/feature-use-case.md rather than assembling from examples in this file.
2. Server Components vs. Client Components
Every component is a server component by default. "use client" is an explicit opt-in that MUST be justified with a comment on the same line or the line immediately above explaining why the client boundary is needed.
| Capability | Server Component | Client Component |
|---|---|---|
| Can fetch data directly | Yes | No (use TanStack Query or props) |
Can use useState / useEffect | No | Yes |
| Can use browser APIs | No | Yes |
| Can handle click events | No | Yes |
| Sends JavaScript to browser | No | Yes |
| Can import server-only modules | Yes | No |
| Runs on server | Yes | No (runs in browser) |
// GOOD: server component fetches data, passes to client component// app/(main)/posts/[id]/page.tsxtype Props = { params: Promise<{ id: string }>}
async function Page({ params }: Props) { const { id } = await params const post = await getPostById(id) if (!post) notFound() return <PostDetail post={post} />}
// GOOD: client component only handles interaction// domain/posts/detail/PostPublishButton.tsx"use client"// Needs onClick handler and isPending state - client component required.
type PostPublishButtonProps = { postId: string onPublish: () => void}
function PostPublishButton({ postId, onPublish }: PostPublishButtonProps) { const [isPending, startTransition] = useTransition() // ...}// BAD: client component fetches data it could receive as props from a server component"use client"
function PostDetail({ postId }: { postId: string }) { const [post, setPost] = useState(null) useEffect(() => { fetch(`/api/posts/${postId}`).then(r => r.json()).then(setPost) }, [postId]) // BAD: useEffect for data fetching, BAD: client component for read-only data}3. File Conventions
| File | Purpose | Notes |
|---|---|---|
page.tsx | Renders the UI for a route segment | Required for a route to be publicly accessible. MUST be a thin shell (see Section 4). |
layout.tsx | Wraps child segments with shared UI | Does not re-render on navigation within its subtree. |
loading.tsx | Suspense boundary UI for a segment | Shown while the segment’s page.tsx is streaming. |
error.tsx | Error boundary for a segment | MUST be a client component ("use client"). |
not-found.tsx | UI shown when notFound() is called | Can be a server component. |
route.ts | HTTP Route Handler for the segment | MUST NOT be used except for OAuth callbacks, webhooks, or binary/file responses. Otherwise use Server Actions or server component fetching. |
proxy.ts | Runs before every request (renamed from middleware.ts) | middleware.ts is deprecated in Next.js 16. Run npx @next/codemod@latest middleware-to-proxy to migrate (run once when upgrading from Next.js 15; renames the file and updates the exported function name). MUST perform only optimistic checks (see Section 6). |
template.tsx | Like layout.tsx but re-renders on navigation | Use only when a fresh instance is explicitly required on each navigation. |
default.tsx | Fallback UI for parallel routes | Required when a slot has no matching segment during soft navigation. |
4. The Page-as-Thin-Shell Pattern
Every page.tsx file in app/ is a thin shell. The page file handles Next.js-specific concerns only: params and searchParams extraction (with await), notFound() calls, and metadata export. The feature component handles all rendering.
// GOOD: app/(main)/posts/[id]/page.tsx - thin shellimport type { Metadata } from "next"import { notFound } from "next/navigation"import { PostDetailPage } from "@/features/posts/detail/PostDetailPage"import { getPostById } from "@/features/posts/detail/queries"
type Props = { params: Promise<{ id: string }>}
export async function generateMetadata({ params }: Props): Promise<Metadata> { const { id } = await params const post = await getPostById(id) if (!post) return {} return { title: post.title }}
export default async function Page({ params }: Props) { const { id } = await params const post = await getPostById(id) if (!post) notFound() return <PostDetailPage post={post} />}// BAD: page.tsx contains component logic and data fetching mixed with routing concernsexport default async function Page({ params }: { params: Promise<{ id: string }> }) { const { id } = await params const post = await db.posts.findById(id) // BAD: db access in page file return ( <div> <h1>{post.title}</h1> {/* BAD: rendering logic in page file */} <p>{post.content}</p> </div> )}5. Route Groups
Route groups use parentheses to organize routes without affecting the URL path. The primary use case is applying different layouts to different sections of the app.
app/ (auth)/ login/ page.tsx register/ page.tsx layout.tsx <- auth layout (centered card, no sidebar) (main)/ dashboard/ page.tsx posts/ page.tsx [id]/ page.tsx layout.tsx <- main app layout (sidebar, nav bar) layout.tsx <- root layout (html, body, providers)flowchart TD Root["app/layout.tsx\n(root layout: html, body, providers)"] Auth["app/(auth)/layout.tsx\n(centered card, no nav)"] Main["app/(main)/layout.tsx\n(sidebar + nav bar)"] Login["app/(auth)/login/page.tsx"] Register["app/(auth)/register/page.tsx"] Dashboard["app/(main)/dashboard/page.tsx"] Posts["app/(main)/posts/page.tsx"] PostDetail["app/(main)/posts/[id]/page.tsx"]
Root --> Auth Root --> Main Auth --> Login Auth --> Register Main --> Dashboard Main --> Posts Main --> PostDetail6. proxy.ts (Renamed from middleware.ts)
proxy.ts runs before every request. It MAY combine two non-authoritative concerns in one file:
- Optimistic auth redirect: verify session cookie presence and redirect unauthenticated users.
- CSP nonce: generate a per-request nonce and set the
Content-Security-Policyheader.
Copy the combined implementation from docs/blueprints/frontend/proxy-ts.md. For CSP layout wiring, see docs/blueprints/frontend/csp-headers.md.
It MUST NOT perform authoritative validation: no database lookups, no cryptographic JWT verification, no permission checks.
CVE-2025-29927: Relying on proxy.ts as the sole authorization gate is a known vulnerability. Protected route groups MUST call auth() in their layout.tsx and redirect() when unauthenticated. proxy.ts is a UX optimization only, not a security boundary.
// GOOD: protected route group layout enforces auth (authoritative)// app/(main)/layout.tsximport { auth } from "@/lib/auth"import { redirect } from "next/navigation"
export default async function MainLayout({ children }: { children: React.ReactNode }) { const session = await auth() if (!session) { redirect("/login") }
return <>{children}</>}// GOOD: use the combined blueprint (auth redirect + CSP nonce)// See docs/blueprints/frontend/proxy-ts.md// BAD: proxy.ts performs cryptographic JWT verificationimport { jwtVerify } from "jose" // BAD: crypto in proxy, slow, error-prone
export async function proxy(request: NextRequest) { const token = request.cookies.get("token")?.value try { await jwtVerify(token, secret) // BAD: authoritative check in proxy } catch { return NextResponse.redirect(new URL("/login", request.url)) }}7. Environment Variables
The NEXT_PUBLIC_ prefix exposes a variable to the browser bundle. Variables without this prefix are server-only and never sent to the client.
| Variable | Scope | Purpose |
|---|---|---|
API_BASE_URL | Server-only | The ASP.NET Core backend API base URL. Never exposed to the browser. |
NEXT_PUBLIC_APP_URL | Public (browser) | The frontend application URL. Used for canonical links and redirects. |
AUTH_SECRET | Server-only | Auth.js v5 secret key for signing session tokens. |
AUTH_* | Server-only | All Auth.js v5 provider configuration variables use the AUTH_ prefix. |
Never put secrets in NEXT_PUBLIC_* variables. They are inlined into the JavaScript bundle and visible to anyone who inspects the page source.
8. The React Compiler
Next.js 16 includes the React Compiler as a stable feature. When enabled via reactCompiler: true in next.config.js, the compiler automatically inserts memoization where it is beneficial. MUST NOT manually add useMemo, useCallback, or React.memo when the compiler is enabled. Manual memoization does not break anything but is redundant noise that makes the code harder to read.
const nextConfig = { reactCompiler: true,}// GOOD: clean component code, compiler handles memoizationfunction ExpensiveList({ items }: { items: Item[] }) { const sorted = items.sort((a, b) => a.name.localeCompare(b.name)) return <ul>{sorted.map(item => <li key={item.id}>{item.name}</li>)}</ul>}// BAD: manual memoization when compiler is enabledfunction ExpensiveList({ items }: { items: Item[] }) { const sorted = useMemo( () => items.sort((a, b) => a.name.localeCompare(b.name)), [items] ) // BAD: useMemo is redundant when the React Compiler is enabled return <ul>{sorted.map(item => <li key={item.id}>{item.name}</li>)}</ul>}9. Caching and Revalidation
Next.js 16 stabilizes the use cache directive for the Cache Components model. Cache behavior is controlled by cacheLife profiles ("seconds", "minutes", "hours", "days", "weeks", "max").
revalidateTag in Next.js 16 requires a cacheLife second argument. Omitting it is a TypeScript error.
// GOOD: revalidateTag with cacheLife second argument (required in Next.js 16)import { revalidateTag } from "next/cache"
revalidateTag("posts", "minutes") // cacheLife second argument required// BAD: revalidateTag without cacheLife (TypeScript error in Next.js 16)revalidateTag("posts") // BAD: missing cacheLife argumentrevalidatePath remains available for simpler cases where tag-based granularity is not needed:
import { revalidatePath } from "next/cache"
revalidatePath("/posts") // revalidates all cached data for the /posts routeFor functions that read from the database or external APIs, the use cache directive marks the function result as cacheable:
import { getApiClient } from "@/lib/api/client"
export async function getPublishedPosts() { "use cache" const client = await getApiClient() const { data } = await client.GET("/posts", { params: { query: { status: "published" } } }) return data}Authenticated data and use cache
MUST NOT use use cache on functions that call getApiClient() with a user-specific Bearer token unless the cache key includes the authenticated user identity. Cached authenticated responses without a user-scoped key leak data across sessions.
// GOOD: no use cache on authenticated fetch; fetch in Server Component per requestexport async function getMyPosts() { const session = await auth() if (!session) { redirect("/login") }
const client = await getApiClient() const { data } = await client.GET("/posts/mine") return data}
// BAD: use cache on authenticated API call without user in cache keyexport async function getMyPosts() { "use cache" // BAD: same cached payload served to every user const client = await getApiClient() const { data } = await client.GET("/posts/mine") return data}Public, unauthenticated data MAY use use cache when the response is identical for all users.
10. TypeScript Configuration
Projects MUST use this tsconfig.json baseline for Next.js 16 with TypeScript 6.0:
{ "compilerOptions": { "target": "ES2022", "lib": ["dom", "dom.iterable", "esnext"], "module": "ESNext", "moduleResolution": "bundler", "moduleDetection": "force", "jsx": "preserve", "strict": true, "noEmit": true, "esModuleInterop": true, "resolveJsonModule": true, "isolatedModules": true, "incremental": true, "plugins": [{ "name": "next" }], "paths": { "@/*": ["./*"] } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "exclude": ["node_modules"]}moduleResolution MUST be "bundler" for Next.js projects. The --outFile compiler option has been removed in TypeScript 6.0 and MUST NOT be used.
11. Feature Use Case Modules
Frontend feature folders follow the same Feature → Use case boundaries as backend handlers and domain docs. Each feature folder owns its routes, components, actions, hooks, and stores for one bounded area (for example posts).
Import rules:
- Code under
features/{feature}/MUST NOT import fromfeatures/{otherFeature}/. - Cross-feature reuse MUST follow the promotion rule in
docs/conventions/principles.md: promote shared implementation code toshared/(import as@/shared/...) or generic UI tocomponents/ui/. app/route shells MUST import feature entry components fromfeatures/{feature}/only. They MUST NOT contain business logic.
// GOOD: posts domain imports only its own modules and shared layersimport { PostCard } from "@/features/posts/list/PostCard"import { formatRelativeDate } from "@/shared/dates/formatRelativeDate"import { Button } from "@/components/ui/button"// BAD: posts domain imports authors domain internalsimport { AuthorAvatar } from "@/features/authors/shared/AuthorAvatar"// BAD: cross-feature coupling; promote AuthorAvatar to @/shared/ or components/ui/Feature-local shared code (features/{feature}/shared/) is for reuse within one domain only. It MUST NOT be imported by other features.
See docs/conventions/frontend/feature-boundaries.md.
12. Environment Variable Validation
Define lib/env.ts in every project. Validate all required server-side environment variables at module load time so a missing variable causes an immediate startup failure with a clear error message, not a cryptic TypeError at request time.
import { z } from "zod"
const serverEnvSchema = z.object({ API_BASE_URL: z.string().url({ error: "API_BASE_URL must be a valid URL" }), AUTH_SECRET: z.string().min(32, { error: "AUTH_SECRET must be at least 32 characters" }),})
// Runs at module import time on the server.// A missing variable causes an immediate startup failure with a clear error message.export const serverEnv = serverEnvSchema.parse({ API_BASE_URL: process.env.API_BASE_URL, AUTH_SECRET: process.env.AUTH_SECRET,})Use serverEnv in lib/api/client.ts instead of process.env directly:
import { serverEnv } from "@/lib/env"
export async function getApiClient() { // ... return createClient<paths>({ baseUrl: serverEnv.API_BASE_URL, // ... })}Add all required server-side variables to the schema. A Zod validation error at startup names the missing variable and the violated constraint.
13. Project-Specific Configuration
Document project-specific API URLs and env values in the relevant use case doc under docs/domain/ and in validated lib/env.ts. Do not extend this standards file.