| name | nextjs-dev |
| description | Next.js 16 development patterns from Vercel's official Next.js Agent Skills (394 snippets). App Router, RSC boundaries, cache components, Turbopack, proxy.ts, Server Actions, metadata/SEO. |
| user-invocable | false |
Next.js 16 Development Patterns (Official Vercel Source)
Based on /vercel-labs/next-skills — the official Vercel-maintained skill pack for Next.js agents.
File Conventions (App Router)
| File | Purpose | Scope |
|---|
page.tsx | UI unique to a route | Route segment |
layout.tsx | Shared UI wrapping children | Route segment + children |
loading.tsx | Instant loading UI (Suspense) | Route segment |
error.tsx | Error boundary ('use client' required) | Route segment |
not-found.tsx | 404 UI | Route segment |
route.ts | API route handler | Route segment |
template.tsx | Re-rendered layout (no state persistence) | Route segment |
default.tsx | Parallel route fallback | Parallel slot |
global-error.tsx | Root error boundary | App-wide |
proxy.ts | Request rewriting/routing (replaces middleware in v16) | Project root |
Route Groups
src/app/
(marketing)/ # force-static — landing, blog, docs
(app)/ # force-dynamic — dashboard, settings
(auth)/ # auth flows — login, register, callback
React Server Component (RSC) Boundaries
Decision Tree
- Does it handle user interaction (clicks, forms, state)? →
'use client'
- Does it only render data? → Server Component (default)
- Does it need browser APIs (window, localStorage)? →
'use client'
- Is it heavy (chart, editor, map)? →
next/dynamic with ssr: false
- Does it fetch data? → Server Component with
async + direct DB/API call
Directives
'use server'
'use client'
'use cache'
Boundary Rules
- Server Components can import Client Components ✅
- Client Components CANNOT import Server Components ❌
- Client Components CAN render Server Components as
children ✅
'use client' marks the boundary — everything imported below is client
import ClientShell from './client-shell'
import ServerData from './server-data'
export default function Page() {
return (
<ClientShell>
<ServerData /> {/* stays on server */}
</ClientShell>
)
}
Async Patterns (Next.js 16)
params and searchParams are Promises
export default async function Page({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
return <Article slug={slug} />
}
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
return { title: `Article: ${slug}` }
}
Parallel Data Fetching — ALWAYS for independent data
const [user, posts, comments] = await Promise.all([
getUser(id),
getPosts(id),
getComments(id),
])
const user = await getUser(id)
const posts = await getPosts(id)
const comments = await getComments(id)
Suspense Streaming
import { Suspense } from 'react'
export default function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<CardSkeleton />}>
<RevenueChart /> {/* streams when ready */}
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<LatestInvoices /> {/* streams independently */}
</Suspense>
</div>
)
}
Cache Components (Next.js 16 PPR)
async function CachedProductList() {
'use cache'
const products = await db.product.findMany()
return <ProductGrid products={products} />
}
import { cacheLife } from 'next/cache'
async function CachedStats() {
'use cache'
cacheLife('hours')
const stats = await getStats()
return <StatsDisplay stats={stats} />
}
import { cacheTag } from 'next/cache'
async function CachedList({ tag }: { tag: string }) {
'use cache'
cacheTag(tag)
const items = await getItems()
return <ItemList items={items} />
}
Server Actions
'use server'
import { z } from 'zod'
import { revalidatePath, revalidateTag } from 'next/cache'
import { redirect } from 'next/navigation'
const CreateSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
})
export async function createUser(formData: FormData) {
const parsed = CreateSchema.safeParse(Object.fromEntries(formData))
if (!parsed.success) {
return { error: parsed.error.flatten() }
}
const session = await auth()
if (!session) {
return { error: 'Unauthorized' }
}
const user = await db.user.create({ data: parsed.data })
revalidatePath('/users')
redirect(`/users/${user.id}`)
}
'use client'
import { useRouter } from 'next/navigation'
function UpdateButton() {
const router = useRouter()
async function handleUpdate() {
await updateItem(id)
router.refresh()
}
return <button onClick={handleUpdate}>Update</button>
}
Data Fetching Patterns
async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const product = await getProduct(id)
return <ProductView product={product} />
}
export const revalidate = 3600
async function BlogPage() {
const posts = await getPosts()
return <PostList posts={posts} />
}
export async function generateStaticParams() {
const posts = await getPosts()
return posts.map((post) => ({ slug: post.slug }))
}
export const dynamic = 'force-dynamic'
Route Handlers
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
const Schema = z.object({
name: z.string().min(1).max(100),
})
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const parsed = Schema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.flatten() },
{ status: 400 }
)
}
const result = await createResource(parsed.data)
return NextResponse.json(result, { status: 201 })
} catch (error) {
console.error('[API] POST /api/resource:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
proxy.ts (Replaces middleware.ts in Next.js 16)
import type { NextRequest } from 'next/server'
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl
if (pathname.startsWith('/blog')) {
return { rewrite: new URL('/marketing/blog' + pathname.slice(5), request.url) }
}
if (pathname.startsWith('/dashboard') && !request.cookies.get('session')) {
return { redirect: new URL('/login', request.url) }
}
}
Metadata / SEO
export const metadata = {
title: 'My App',
description: 'App description',
openGraph: {
title: 'My App',
description: 'App description',
images: ['/og.png'],
},
}
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const post = await getPost(slug)
return {
title: post.title,
description: post.excerpt,
openGraph: { images: [post.coverImage] },
}
}
import { ImageResponse } from 'next/og'
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const title = searchParams.get('title') ?? 'Default Title'
return new ImageResponse(
<div style={{ fontSize: 48, background: 'white', width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{title}
</div>,
{ width: 1200, height: 630 }
)
}
Parallel & Intercepting Routes
export default function Layout({
children,
analytics,
team,
}: {
children: React.ReactNode
analytics: React.ReactNode
team: React.ReactNode
}) {
return (
<div>
{children}
{analytics}
{team}
</div>
)
}
Import Rules (Critical for Bundle Size)
import { Button, Card, Input } from '@/components/ui'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import HeavyChart from '@/components/heavy-chart'
import dynamic from 'next/dynamic'
const HeavyChart = dynamic(() => import('@/components/heavy-chart'), {
ssr: false,
loading: () => <ChartSkeleton />,
})
Turbopack (Default in Next.js 16)
- Turbopack is the default bundler for both
next dev and next build
- No webpack configuration needed for standard use cases
- Built-in CSS, PostCSS, and Tailwind CSS support
- Filesystem caching enabled by default
const config = {
experimental: {
turbo: {
},
},
}
Environment Variables
NEXT_PUBLIC_* — exposed to client bundle (public values only)
- Everything else — server-only (API keys, secrets, DB URLs)
- Never import
process.env in client components without NEXT_PUBLIC_ prefix
- Use
src/types/env.d.ts for typed environment variables
Error Handling
'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>
)
}
'use client'
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<html>
<body>
<h2>Something went wrong</h2>
<button onClick={reset}>Try again</button>
</body>
</html>
)
}