---
name: nextjs-best-practices
description: Enforces Next.js 16 best practices for App Router, React Server Components, caching, routing, TypeScript, and performance. Use when building, reviewing, or refactoring Next.js applications, creating pages/layouts/routes, configuring next.config.ts, handling data fetching, or working with server/client components.
---
# Next.js 16 Best Practices
## Architecture: Server vs Client Components
- Default to Server Components; add `'use client'` only at interactive leaf nodes
- Push `'use client'` as deep in the tree as possible to minimize client JS
- Never fetch data or access server resources in Client Components
- Compose: wrap Client Components with Server Component data-passing, not the reverse
```tsx
// ✅ Server Component fetches, Client Component is interactive leaf
async function ProductPage({ id }: { id: string }) {
const product = await fetchProduct(id);
return ;
}
// ✅ Client leaf
'use client';
export function AddToCartButton({ product }: { product: Product }) { ... }
```
## Caching: Opt-In with `"use cache"`
Caching is **opt-in** in Next.js 16 — all code executes dynamically by default.
```ts
// next.config.ts
const nextConfig = { cacheComponents: true };
export default nextConfig;
```
```tsx
// Cache a page, component, or function
'use cache';
import { cacheLife } from 'next/cache';
export async function getProducts() {
cacheLife('hours');
return await db.products.findAll();
}
```
### Cache Invalidation APIs
| Scenario | API |
|---|---|
| Static content, eventual consistency | `revalidateTag('tag', 'max')` |
| User action, read-your-writes | `updateTag('tag')` in Server Action |
| Uncached dynamic data refresh | `refresh()` in Server Action |
```ts
'use server';
import { updateTag } from 'next/cache';
export async function saveProfile(userId: string, data: Profile) {
await db.users.update(userId, data);
updateTag(`user-${userId}`); // User sees changes immediately
}
```
## Routing & Navigation
### File Conventions
```
app/
├── layout.tsx # Root layout (required)
├── page.tsx # Route segment
├── loading.tsx # Suspense fallback
├── error.tsx # Error boundary ('use client' required)
├── not-found.tsx # 404 handler
└── (group)/ # Route group (no URL segment)
└── page.tsx
```
### Async Params (Breaking Change in v16)
```tsx
// ✅ v16 — params and searchParams are async
export default async function Page({
params,
searchParams,
}: {
params: Promise<{ slug: string }>;
searchParams: Promise<{ q?: string }>;
}) {
const { slug } = await params;
const { q } = await searchParams;
}
```
### Parallel Routes
All parallel route slots require an explicit `default.tsx`:
```tsx
// app/@modal/default.tsx
export default function Default() { return null; }
```
## Async APIs (Breaking Change in v16)
```ts
// ✅ All must be awaited
import { cookies, headers, draftMode } from 'next/headers';
const cookieStore = await cookies();
const headersList = await headers();
const { isEnabled } = await draftMode();
```
## `proxy.ts` (replaces `middleware.ts`)
```ts
// proxy.ts (root of project)
import { NextRequest, NextResponse } from 'next/server';
export default function proxy(request: NextRequest) {
if (!request.cookies.get('token')) {
return NextResponse.redirect(new URL('/login', request.url));
}
}
export const config = { matcher: ['/dashboard/:path*'] };
```
> `middleware.ts` is deprecated — rename to `proxy.ts` and rename the export to `proxy`.
## Performance
### Streaming with Suspense
Data must be fetched **inside** the Suspense boundary for streaming to work.
Create an async loader component — never fetch above Suspense and pass data down:
```tsx
// ✅ Async loader fetches INSIDE Suspense — skeleton streams while data loads
async function DataLoader() {
const items = await getItems()
return
}
export default function Page() {
return (
<>
}>
>
)
}
// ❌ BAD — fetching ABOVE Suspense defeats streaming
export default async function Page() {
const items = await getItems() // blocks entire page
return (
}>
{/* skeleton never shows */}
)
}
```
### Query Layer (`lib/queries/`)
Separate read queries into `lib/queries/` — never call the ORM directly in page components:
```ts
// lib/queries/todos.ts
import { prisma } from '@/lib/prisma'
import type { Priority, Todo } from '@/lib/types'
export async function getTodos(priorityFilter?: Priority): Promise {
return prisma.todo.findMany({
where: priorityFilter ? { priority: priorityFilter } : undefined,
orderBy: { createdAt: 'desc' },
})
}
```
Pages import from query functions; Server Actions handle mutations in `actions/`.
### Constants-First
Never hardcode enum values — derive from shared constants:
```ts
// ✅ Derive from single source of truth
import { PRIORITY_OPTIONS } from '@/lib/constants/priorities'
const VALID_PRIORITIES = new Set(PRIORITY_OPTIONS.map((o) => o.value))
// ❌ BAD — hardcoded, will drift from schema
const VALID_PRIORITIES = new Set(['LOW', 'MEDIUM', 'HIGH'])
```
### React Compiler (Stable)
Enables automatic memoization — eliminates manual `useMemo`/`useCallback`:
```ts
// next.config.ts
const nextConfig = { reactCompiler: true };
```
```bash
pnpm add babel-plugin-react-compiler@latest
```
### Turbopack (Default)
Turbopack is now the default bundler. Remove conflicting Webpack configs or opt out:
```bash
next dev --webpack # Opt out of Turbopack
```
Enable filesystem caching for large projects:
```ts
experimental: { turbopackFileSystemCacheForDev: true }
```
## TypeScript
- Target TypeScript 5.1+, Node.js 20.9+
- Define explicit return types on all async functions
- Use `Promise` types for params/searchParams
- Use `next.config.ts` (TypeScript native config)
```ts
// ✅ Typed route handler
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
): Promise {
const { id } = await params;
const data = await fetchById(id);
return Response.json(data);
}
```
## Image Optimization
```tsx
import Image from 'next/image';
// Remote images require remotePatterns (not deprecated domains)
// next.config.ts
images: {
remotePatterns: [{ protocol: 'https', hostname: 'example.com' }],
}
```
## Server Actions
```ts
'use server';
export async function createPost(formData: FormData): Promise {
const title = formData.get('title') as string;
await db.posts.create({ title });
updateTag('posts');
}
```
- Colocate with the component or in a dedicated `actions/` directory
- Always validate and sanitize inputs
- Use `updateTag` for read-your-writes after mutations
## Environment & Config
```ts
// next.config.ts — use env vars, not deprecated serverRuntimeConfig/publicRuntimeConfig
const nextConfig = {
cacheComponents: true,
reactCompiler: true,
images: {
remotePatterns: [{ protocol: 'https', hostname: 'cdn.example.com' }],
},
};
export default nextConfig;
```
FOR SSR BEST PRACTICES LOOK HERE:
/.cursor/skills/nextjs-best-practices/ssr-best-practices
## Breaking Changes Checklist (v15 → v16)
- [ ] `params` / `searchParams` → `await params` / `await searchParams`
- [ ] `cookies()` / `headers()` / `draftMode()` → add `await`
- [ ] `middleware.ts` → rename to `proxy.ts`, export `proxy` function
- [ ] `revalidateTag('tag')` → `revalidateTag('tag', 'max')`
- [ ] `experimental.dynamicIO` → `cacheComponents: true`
- [ ] `experimental.ppr` → removed; use Cache Components
- [ ] `images.domains` → `images.remotePatterns`
- [ ] Parallel routes missing `default.tsx` → add for all slots
- [ ] `serverRuntimeConfig` / `publicRuntimeConfig` → use `.env` files
- [ ] Remove `next lint` from CI → use `eslint` or `biome` directly
## Additional Resources
- [Next.js 16 Release Notes](https://nextjs.org/blog/next-16)
- [Upgrade Guide v16](https://nextjs.org/docs/app/guides/upgrading/version-16)
- [Cache Components Docs](https://nextjs.org/docs/app/api-reference/config/next-config-js/cacheComponents)
- [proxy.ts Docs](https://nextjs.org/docs/app/getting-started/proxy)
- [DevTools MCP](https://nextjs.org/docs/app/guides/mcp)