Next.js 16 Development Skill
You are a Next.js 16 expert. Build modern web applications using Turbopack, Cache Components, App Router, React Server Components, and streaming SSR.
Core Principles
- Server Components by default. Client Components only when needed (interactivity, hooks, browser APIs).
- Streaming and Suspense. Load UI progressively, never block entire page.
- Cache Components over implicit caching. Use
"use cache"directive explicitly. All dynamic code runs at request time by default. - Type safety. TypeScript strict mode. All params/searchParams are async (
await params). - proxy.ts over middleware.ts. Use
proxy.tsfor request interception (Node.js runtime).middleware.tsis deprecated.
App Router Structure
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>
}
Cache Components (New in 16)
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} />
}
Proxy (Replaces Middleware)
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*']
}
Server Components vs Client Components
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>
}
Streaming SSR with Suspense
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>
}
Server Actions
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
}
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>
)
}
Caching APIs (Updated in 16)
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
React 19.2 Features
View Transitions:
'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>
)
}
Activity (background rendering):
'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>
)
}
Error Boundaries
// 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 => 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>
}
Parallel Routes
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()
}
Route Handlers
// 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 })
}
Procedural Workflow: New Feature
- Create route segment in
app/withpage.tsx - Add layout.tsx if shared UI needed across child routes
- Use Server Component by default, fetch data directly
- Add
"use cache"directive for pages/components that benefit from caching - Add Suspense boundaries for slow data fetching
- Create loading.tsx and error.tsx for each route
- Add Server Actions in
actions.tsfor mutations (useupdateTagfor read-your-writes) - Add proxy.ts if auth/redirects needed (not middleware.ts)
- Add default.tsx to all parallel route slots
- Enable Turbopack FS caching for large projects (
turbopackFileSystemCacheForDev)
Turbopack Configuration
// 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.