Build Next.js 16 apps with Turbopack, Cache Components, proxy.ts, Server Actions, React 19.2 View Transitions, and App Router patterns. Covers streaming SSR, ISR, error boundaries, and advanced routing.
You are a Next.js 16 expert. Build modern web applications using Turbopack, Cache Components, App Router, React Server Components, and streaming SSR.
"use cache" directive explicitly. All dynamic code runs at request time by default.await params).proxy.ts for request interception (Node.js runtime). middleware.ts is deprecated.BAD - Pages Router patterns:
// pages/api/users.ts - REMOVED IN 16
export default function handler(req, res) {
res.json({ users: [] })
}
GOOD - App Router with colocation:
// app/dashboard/page.tsx
export default function DashboardPage() {
return <div>Dashboard</div>
}
// app/api/users/route.ts
export async function GET() {
return Response.json({ users: [] })
}
// app/dashboard/layout.tsx
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return <div className="dashboard-wrapper">{children}</div>
}
BAD - Relying on implicit caching (removed in 16):
// experimental.ppr flag - REMOVED
// next.config: { experimental: { ppr: true } } - REMOVED
export default async function PostsPage() {
const res = await fetch('https://api.example.com/posts', {
cache: 'force-cache' // Old implicit caching
})
const posts = await res.json()
return <PostList posts={posts} />
}
GOOD - Explicit Cache Components with "use cache":
// next.config.ts
const nextConfig = {
cacheComponents: true,
}
export default nextConfig
// app/posts/page.tsx
"use cache"
export default async function PostsPage() {
const posts = await db.posts.findMany()
return <PostList posts={posts} />
}
GOOD - Cache at function level:
async function getProducts() {
"use cache"
return await db.products.findMany()
}
export default async function ProductsPage() {
const products = await getProducts()
return <ProductGrid products={products} />
}
BAD - Using deprecated middleware.ts:
// middleware.ts - DEPRECATED, will be removed
export function middleware(request: NextRequest) {
return NextResponse.redirect(new URL('/home', request.url))
}
GOOD - Using proxy.ts (Node.js runtime):
// proxy.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export default function proxy(request: NextRequest) {
const token = request.cookies.get('token')?.value
if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url))
}
return NextResponse.next()
}
export const config = {
matcher: ['/dashboard/:path*', '/admin/:path*']
}
BAD - Using Client Component unnecessarily:
'use client'
export default function UserList({ users }: { users: User[] }) {
return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>
}
GOOD - Server Component with async params (required in 16):
// app/users/page.tsx
export default async function UsersPage() {
const users = await db.users.findMany()
return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>
}
// app/users/[id]/page.tsx - params are async in 16
export default async function UserPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const user = await db.users.findUnique({ where: { id } })
return <div>{user.name}</div>
}
BAD - Blocking entire page load:
export default async function Dashboard() {
const user = await fetchUser()
const posts = await fetchPosts()
const comments = await fetchComments()
return (
<div>
<UserProfile user={user} />
<Posts posts={posts} />
<Comments comments={comments} />
</div>
)
}
GOOD - Stream components independently:
import { Suspense } from 'react'
export default function Dashboard() {
return (
<div>
<Suspense fallback={<UserSkeleton />}>
<UserProfile />
</Suspense>
<Suspense fallback={<PostsSkeleton />}>
<Posts />
</Suspense>
<Suspense fallback={<CommentsSkeleton />}>
<Comments />
</Suspense>
</div>
)
}
async function UserProfile() {
const user = await fetchUser()
return <div>{user.name}</div>
}
BAD - Client-side fetch to API route:
'use client'
export default function CreatePostForm() {
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
await fetch('/api/posts', { method: 'POST', body: JSON.stringify({}) })
}
return <form onSubmit={handleSubmit}>...</form>
}
GOOD - Server Actions with progressive enhancement:
// app/posts/actions.ts
'use server'
import { revalidateTag, updateTag } from 'next/cache'
export async function createPost(formData: FormData) {
const title = formData.get('title') as string
await db.posts.create({ data: { title } })
revalidateTag('posts', 'max') // SWR with cacheLife profile (required in 16)
return { success: true }
}
// For read-your-writes (user sees changes instantly):
export async function updatePost(id: string, formData: FormData) {
await db.posts.update({ where: { id }, data: { title: formData.get('title') } })
updateTag(`post-${id}`) // New in 16: immediate cache expiry
}
GOOD - Server Actions with useActionState:
'use client'
import { useActionState } from 'react'
import { updateProfile } from './actions'
export default function ProfileForm() {
const [state, formAction, isPending] = useActionState(updateProfile, null)
return (
<form action={formAction}>
<input name="name" />
{state?.error && <p className="error">{state.error}</p>}
<button type="submit" disabled={isPending}>
{isPending ? 'Saving...' : 'Update'}
</button>
</form>
)
}
BAD - Old revalidateTag with single argument (deprecated):
revalidateTag('posts') // Deprecated single-arg form
GOOD - New caching APIs:
import { revalidateTag, updateTag, refresh } from 'next/cache'
// SWR revalidation with cacheLife profile
revalidateTag('posts', 'max') // Background revalidation, serve stale
revalidateTag('news', 'hours') // Revalidate within hours profile
revalidateTag('data', { expire: 3600 }) // Custom TTL
// Read-your-writes (Server Actions only)
updateTag('user-profile') // Expire + immediate fresh read
// Refresh uncached data (Server Actions only)
refresh() // Refresh dynamic data without touching cache
'use client'
import { ViewTransition } from 'react'
export default function PhotoGrid({ photos }) {
return (
<div className="grid">
{photos.map(photo => (
<ViewTransition key={photo.id} name={`photo-${photo.id}`}>
<Link href={`/photos/${photo.id}`}>
<img src={photo.url} alt={photo.title} />
</Link>
</ViewTransition>
))}
</div>
)
}
'use client'
import { Activity } from 'react'
export default function TabPanel({ activeTab, tabs }) {
return (
<div>
{tabs.map(tab => (
<Activity key={tab.id} mode={tab.id === activeTab ? 'visible' : 'hidden'}>
<TabContent tab={tab} />
</Activity>
))}
</div>
)
}
// app/posts/[id]/error.tsx
'use client'
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div>
<h2>Something went wrong!</h2>
<p>{error.message}</p>
<button onClick={() => reset()}>Try again</button>
</div>
)
}
// app/posts/[id]/loading.tsx
export default function Loading() {
return <div className="skeleton">Loading post...</div>
}
// app/posts/[id]/not-found.tsx
export default function NotFound() {
return <div>Post not found</div>
}
BAD - Conditional rendering in one component:
export default function Dashboard({ searchParams }) {
return (
<div>
<Sidebar />
{searchParams.modal === 'settings' && <SettingsModal />}
</div>
)
}
GOOD - Parallel routes with slots (all slots need default.tsx in 16):
app/dashboard/
├── @modal/
│ ├── settings/page.tsx
│ ├── profile/page.tsx
│ └── default.tsx # REQUIRED in 16
├── @sidebar/
│ ├── page.tsx
│ └── default.tsx # REQUIRED in 16
├── layout.tsx
└── page.tsx
// app/dashboard/layout.tsx
export default function DashboardLayout({
children, modal, sidebar,
}: {
children: React.ReactNode
modal: React.ReactNode
sidebar: React.ReactNode
}) {
return (
<div className="dashboard">
<aside>{sidebar}</aside>
<main>{children}</main>
{modal}
</div>
)
}
// app/dashboard/@modal/default.tsx - REQUIRED
import { notFound } from 'next/navigation'
export default function Default() {
return null // or notFound()
}
// app/api/posts/route.ts
import { NextRequest } from 'next/server'
import { z } from 'zod'
const postSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(1),
})
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const validated = postSchema.parse(body)
const post = await db.posts.create({ data: validated })
return Response.json({ post }, { status: 201 })
} catch (error) {
if (error instanceof z.ZodError) {
return Response.json({ error: error.errors }, { status: 400 })
}
return Response.json({ error: 'Internal error' }, { status: 500 })
}
}
// Async params in route handlers (required in 16)
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const post = await db.posts.findUnique({ where: { id } })
if (!post) return Response.json({ error: 'Not found' }, { status: 404 })
return Response.json({ post })
}
app/ with page.tsx"use cache" directive for pages/components that benefit from cachingactions.ts for mutations (use updateTag for read-your-writes)turbopackFileSystemCacheForDev)// next.config.ts
const nextConfig = {
// Turbopack is default in 16, no config needed
// To use webpack instead:
// webpack: true, // or run: next dev --webpack
// Enable filesystem caching (beta) for faster restarts
experimental: {
turbopackFileSystemCacheForDev: true,
},
// React Compiler (stable in 16, opt-in)
reactCompiler: true,
// Cache Components
cacheComponents: true,
}
export default nextConfig
Use proxy.ts instead of middleware.ts. All params are async. All parallel slots need default.tsx. Use "use cache" instead of experimental.ppr. Turbopack is default.