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
1// ✅ Server Component fetches, Client Component is interactive leaf
2async function ProductPage({ id }: { id: string }) {
3 const product = await fetchProduct(id);
4 return <AddToCartButton product={product} />;
5}
6
7// ✅ Client leaf
8'use client';
9export 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
1// next.config.ts
2const nextConfig = { cacheComponents: true };
3export default nextConfig;
tsx
1// Cache a page, component, or function
2'use cache';
3import { cacheLife } from 'next/cache';
4
5export async function getProducts() {
6 cacheLife('hours');
7 return await db.products.findAll();
8}
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
1'use server';
2import { updateTag } from 'next/cache';
3
4export async function saveProfile(userId: string, data: Profile) {
5 await db.users.update(userId, data);
6 updateTag(`user-${userId}`); // User sees changes immediately
7}
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
1// ✅ v16 — params and searchParams are async
2export default async function Page({
3 params,
4 searchParams,
5}: {
6 params: Promise<{ slug: string }>;
7 searchParams: Promise<{ q?: string }>;
8}) {
9 const { slug } = await params;
10 const { q } = await searchParams;
11}
Parallel Routes
All parallel route slots require an explicit default.tsx:
tsx
1// app/@modal/default.tsx
2export default function Default() { return null; }
Async APIs (Breaking Change in v16)
ts
1// ✅ All must be awaited
2import { cookies, headers, draftMode } from 'next/headers';
3
4const cookieStore = await cookies();
5const headersList = await headers();
6const { isEnabled } = await draftMode();
proxy.ts (replaces middleware.ts)
ts
1// proxy.ts (root of project)
2import { NextRequest, NextResponse } from 'next/server';
3
4export default function proxy(request: NextRequest) {
5 if (!request.cookies.get('token')) {
6 return NextResponse.redirect(new URL('/login', request.url));
7 }
8}
9
10export const config = { matcher: ['/dashboard/:path*'] };
middleware.ts is deprecated — rename to proxy.ts and rename the export to proxy.
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
1// ✅ Async loader fetches INSIDE Suspense — skeleton streams while data loads
2async function DataLoader() {
3 const items = await getItems()
4 return <ItemList items={items} />
5}
6
7export default function Page() {
8 return (
9 <>
10 <StaticHeader />
11 <Suspense fallback={<Skeleton />}>
12 <DataLoader />
13 </Suspense>
14 </>
15 )
16}
17
18// ❌ BAD — fetching ABOVE Suspense defeats streaming
19export default async function Page() {
20 const items = await getItems() // blocks entire page
21 return (
22 <Suspense fallback={<Skeleton />}>
23 <ItemList items={items} /> {/* skeleton never shows */}
24 </Suspense>
25 )
26}
Query Layer (lib/queries/)
Separate read queries into lib/queries/ — never call the ORM directly in page components:
ts
1// lib/queries/todos.ts
2import { prisma } from '@/lib/prisma'
3import type { Priority, Todo } from '@/lib/types'
4
5export async function getTodos(priorityFilter?: Priority): Promise<Todo[]> {
6 return prisma.todo.findMany({
7 where: priorityFilter ? { priority: priorityFilter } : undefined,
8 orderBy: { createdAt: 'desc' },
9 })
10}
Pages import from query functions; Server Actions handle mutations in actions/.
Constants-First
Never hardcode enum values — derive from shared constants:
ts
1// ✅ Derive from single source of truth
2import { PRIORITY_OPTIONS } from '@/lib/constants/priorities'
3const VALID_PRIORITIES = new Set(PRIORITY_OPTIONS.map((o) => o.value))
4
5// ❌ BAD — hardcoded, will drift from schema
6const VALID_PRIORITIES = new Set(['LOW', 'MEDIUM', 'HIGH'])
React Compiler (Stable)
Enables automatic memoization — eliminates manual useMemo/useCallback:
ts
1// next.config.ts
2const nextConfig = { reactCompiler: true };
bash
1pnpm add babel-plugin-react-compiler@latest
Turbopack (Default)
Turbopack is now the default bundler. Remove conflicting Webpack configs or opt out:
bash
1next dev --webpack # Opt out of Turbopack
Enable filesystem caching for large projects:
ts
1experimental: { turbopackFileSystemCacheForDev: true }
TypeScript
- Target TypeScript 5.1+, Node.js 20.9+
- Define explicit return types on all async functions
- Use
Promise<PageProps> types for params/searchParams
- Use
next.config.ts (TypeScript native config)
ts
1// ✅ Typed route handler
2export async function GET(
3 request: Request,
4 { params }: { params: Promise<{ id: string }> }
5): Promise<Response> {
6 const { id } = await params;
7 const data = await fetchById(id);
8 return Response.json(data);
9}
Image Optimization
tsx
1import Image from 'next/image';
2
3// Remote images require remotePatterns (not deprecated domains)
4// next.config.ts
5images: {
6 remotePatterns: [{ protocol: 'https', hostname: 'example.com' }],
7}
Server Actions
ts
1'use server';
2
3export async function createPost(formData: FormData): Promise<void> {
4 const title = formData.get('title') as string;
5 await db.posts.create({ title });
6 updateTag('posts');
7}
- 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
1// next.config.ts — use env vars, not deprecated serverRuntimeConfig/publicRuntimeConfig
2const nextConfig = {
3 cacheComponents: true,
4 reactCompiler: true,
5 images: {
6 remotePatterns: [{ protocol: 'https', hostname: 'cdn.example.com' }],
7 },
8};
9
10export default nextConfig;
FOR SSR BEST PRACTICES LOOK HERE:
/.cursor/skills/nextjs-best-practices/ssr-best-practices
Breaking Changes Checklist (v15 → v16)
Additional Resources