Next.js
Next.js cheat sheet for App Router, Server Components, Server Actions, data fetching, routing, and caching with code examples.
20 min read
nextjsreactssrapp-routerserver-componentsrsctypescript
Sign in to mark items as known and track your progress.
Sign inProject Setup & Structure
Create new Next.js app
📄 Codebash
# Create new Next.js app
npx create-next-app@latest my-app
# With all recommended options
npx create-next-app@latest my-app --typescript --tailwind --app --src-dir
# With package manager choice
npx create-next-app@latest my-app --use-npm
npx create-next-app@latest my-app --use-yarn
npx create-next-app@latest my-app --use-pnpm
npx create-next-app@latest my-app --use-bun💡 Use --typescript flag for TypeScript support
⚡ --tailwind adds Tailwind CSS automatically
📌 App Router + Turbopack are the defaults in 16
🟢 --src-dir creates a src/ directory
App Router file structure
📄 Codetext
app/
├── layout.tsx # Root layout
├── page.tsx # Home page (/)
├── loading.tsx # Loading UI
├── error.tsx # Error UI
├── not-found.tsx # 404 page
├── global.css # Global styles
├── about/
│ └── page.tsx # /about route
├── blog/
│ ├── layout.tsx # Nested layout
│ ├── page.tsx # /blog route
│ └── [slug]/
│ └── page.tsx # Dynamic route /blog/[slug]
└── api/
└── route.ts # API route💡 page.tsx defines a route
⚡ layout.tsx wraps pages and preserves state
📌 loading.tsx shows while page loads
🔥 error.tsx handles errors with error boundary
Configuration files
📄 Codejavascript
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
// Enable React Strict Mode
reactStrictMode: true,
// Configure remote image hosts (domains is deprecated)
images: {
remotePatterns: [
{
protocol: 'https',
hostname: '**.example.com',
},
],
},
// Environment variables
env: {
API_URL: process.env.API_URL,
},
// Redirects
async redirects() {
return [
{
source: '/old-path',
destination: '/new-path',
permanent: true,
},
]
},
}
export default nextConfig💡 next.config.ts (or .js) - typed via NextConfig
⚡ TypeScript config is auto-generated
📌 .env.local for environment variables
🟢 proxy.ts (was middleware.ts) runs before requests
Routing & Navigation
Basic routing
📄 Codetypescript
// app/page.tsx - Home route (/)
export default function HomePage() {
return <h1>Home Page</h1>
}
// app/about/page.tsx - About route (/about)
export default function AboutPage() {
return <h1>About Page</h1>
}
// app/(marketing)/contact/page.tsx - Route group
// URL is /contact (marketing is ignored)
export default function ContactPage() {
return <h1>Contact Page</h1>
}💡 Folders define routes, page.tsx makes them accessible
⚡ Nested folders create nested routes
📌 Use Link component for client-side navigation
🔥 Route groups with () don't affect URL
Dynamic routes
typescript
// app/blog/[slug]/page.tsx💡 [param] for dynamic segments
⚡ [...slug] for catch-all routes
📌 [[...slug]] for optional catch-all
🟢 Access params in page components
Link component & navigation
📄 Codetypescript
'use client'
import Link from 'next/link'
import { useRouter, usePathname, useSearchParams } from 'next/navigation'
export default function NavigationExample() {
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
return (
<nav>
{/* Basic Link */}
<Link href="/about">About</Link>
{/* Dynamic Link */}
<Link href="/blog/my-post">Blog Post</Link>
<Link href={`/products/${productId}`}>Product</Link>
{/* With query params */}
<Link href="/shop?category=electronics">Electronics</Link>
<Link href={{ pathname: '/shop', query: { category: 'books' } }}>
Books
</Link>
{/* Active link styling */}
<Link
href="/dashboard"
className={pathname === '/dashboard' ? 'active' : ''}
>
Dashboard
</Link>
{/* Programmatic navigation */}
<button onClick={() => router.push('/login')}>
Login
</button>
<button onClick={() => router.back()}>
Go Back
</button>
<button onClick={() => router.refresh()}>
Refresh
</button>
{/* Prefetch control */}
<Link href="/heavy-page" prefetch={false}>
Heavy Page (no prefetch)
</Link>
</nav>
)
}💡 Link enables client-side navigation
⚡ Prefetches links in viewport automatically
📌 Use router.push() for programmatic navigation
🔥 usePathname() gets current path
Server & Client Components
Server Components (default)
📄 Codetypescript
// Server Component (default) - No 'use client'
// app/products/page.tsx
interface Product {
id: string
name: string
price: number
}
// Can be async and fetch data directly
export default async function ProductsPage() {
// Direct database access or API calls
const products = await fetch('https://api.example.com/products', {
cache: 'force-cache' // Cache strategy
}).then(res => res.json())
// Can access environment variables directly
const secretKey = process.env.SECRET_API_KEY
// Can import server-only modules
const db = await import('@/lib/db')
const users = await db.getUsers()
return (
<div>
<h1>Products</h1>
{products.map((product: Product) => (
<div key={product.id}>
<h2>{product.name}</h2>
<p>${product.price}</p>
</div>
))}
</div>
)
}
// Server Component with children
export async function ServerWrapper({ children }: { children: React.ReactNode }) {
const data = await fetchData()
return (
<div>
<h1>{data.title}</h1>
{children} {/* Can be Client Components */}
</div>
)
}💡 Components are Server Components by default
⚡ Can fetch data directly with async/await
📌 Reduces JavaScript sent to client
🔥 Cannot use browser APIs or event handlers
Client Components
📄 Codetypescript
'use client' // This directive makes it a Client Component
import { useState, useEffect } from 'react'
interface ClientComponentProps {
initialCount?: number
serverData: string // Props from Server Component
}
export default function InteractiveCounter({
initialCount = 0,
serverData
}: ClientComponentProps) {
const [count, setCount] = useState(initialCount)
const [isClient, setIsClient] = useState(false)
// Can use React hooks
useEffect(() => {
setIsClient(true)
// Can access browser APIs
console.log(window.location.href)
localStorage.setItem('count', count.toString())
}, [count])
// Can use event handlers
const handleClick = () => {
setCount(prev => prev + 1)
}
return (
<div>
<p>Server Data: {serverData}</p>
<p>Count: {count}</p>
<button onClick={handleClick}>Increment</button>
{/* Conditional rendering for client-only features */}
{isClient && (
<p>Window width: {window.innerWidth}px</p>
)}
</div>
)
}
// Composing Server and Client Components
// app/page.tsx (Server Component)
import ClientCounter from './ClientCounter'
export default async function Page() {
const data = await fetchServerData()
return (
<div>
<h1>Server Rendered Title</h1>
{/* Pass server data to Client Component */}
<ClientCounter serverData={data} />
</div>
)
}💡 Add "use client" directive at top
⚡ Can use hooks, browser APIs, event handlers
📌 Still pre-rendered on server (SSR)
🟢 Import Server Components as children, not directly
When to use Server vs Client
📄 Codetypescript
// USE SERVER COMPONENTS WHEN:
// ✅ Fetching data
// ✅ Accessing backend resources directly
// ✅ Keeping sensitive info on server (API keys, etc)
// ✅ Large dependencies (reduces client bundle)
// ✅ Static content without interactivity
// Server Component Example
async function UserProfile({ userId }: { userId: string }) {
// Direct database access
const user = await db.user.findUnique({ where: { id: userId } })
// Access sensitive environment variables
const apiKey = process.env.SECRET_API_KEY
// Import heavy libraries only on server
const bcrypt = await import('bcrypt')
return <div>{user.name}</div>
}
// USE CLIENT COMPONENTS WHEN:
// ✅ onClick, onChange, other event handlers
// ✅ useState, useEffect, other React hooks
// ✅ Browser-only APIs (window, document, localStorage)
// ✅ Class components (if needed)
// ✅ Third-party libs that use browser APIs
// Client Component Example
'use client'
function SearchBar() {
const [query, setQuery] = useState('')
// Need event handler
const handleSearch = (e: React.FormEvent) => {
e.preventDefault()
// Search logic
}
// Need browser API
useEffect(() => {
const saved = localStorage.getItem('lastSearch')
if (saved) setQuery(saved)
}, [])
return (
<form onSubmit={handleSearch}>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</form>
)
}💡 Default to Server Components
⚡ Client for interactivity and browser APIs
📌 Server for data fetching and backend access
🔥 Keep Client Components small and focused
Data Fetching
Fetching in Server Components
📄 Codetypescript
// Simple fetch in Server Component
async function ProductsPage() {
const res = await fetch('https://api.example.com/products')
const products = await res.json()
return (
<div>
{products.map(product => (
<div key={product.id}>{product.name}</div>
))}
</div>
)
}
// Parallel fetching
async function DashboardPage() {
// Fetch in parallel for better performance
const [users, posts, comments] = await Promise.all([
fetch('/api/users').then(res => res.json()),
fetch('/api/posts').then(res => res.json()),
fetch('/api/comments').then(res => res.json()),
])
return (
<div>
<UsersList users={users} />
<PostsList posts={posts} />
<CommentsList comments={comments} />
</div>
)
}
// Sequential when data depends on each other
async function UserPostsPage({ userId }: { userId: string }) {
// First fetch user
const user = await fetch(`/api/users/${userId}`).then(res => res.json())
// Then fetch their posts
const posts = await fetch(`/api/users/${userId}/posts`).then(res => res.json())
return (
<div>
<h1>{user.name}'s Posts</h1>
<PostsList posts={posts} />
</div>
)
}💡 Use async/await directly in components
⚡ Automatic request deduplication
📌 Requests dedupe per render; opt in to caching (force-cache/revalidate)
🟢 Can fetch in parallel with Promise.all
Caching strategies
typescript
// Static data - opt IN to caching (uncached by default since v15)💡 force-cache: opt IN (fetch is no-store by default since v15)
⚡ no-store: always fresh - the default since v15
📌 revalidate: Time-based revalidation
🔥 on-demand revalidation with revalidatePath/Tag
Loading & streaming
📄 Codetypescript
// app/products/loading.tsx
export default function Loading() {
return (
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded w-1/4 mb-4"></div>
<div className="grid grid-cols-3 gap-4">
{[...Array(6)].map((_, i) => (
<div key={i} className="h-40 bg-gray-200 rounded"></div>
))}
</div>
</div>
)
}
// Using Suspense for parts of the page
import { Suspense } from 'react'
export default function ProductPage() {
return (
<div>
<h1>Products</h1>
<Suspense fallback={<div>Loading products...</div>}>
<ProductList /> {/* Async Server Component */}
</Suspense>
<Suspense fallback={<div>Loading reviews...</div>}>
<Reviews /> {/* Another async component */}
</Suspense>
</div>
)
}
// Streaming with generateMetadata
export async function generateMetadata(
{ params }: { params: Promise<{ id: string }> }
) {
// This runs in parallel with the page
const { id } = await params
const product = await getProduct(id)
return {
title: product.name,
description: product.description,
}
}
// Progressive enhancement with loading boundaries
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<div>
<nav>Static Navigation</nav>
<Suspense fallback={<SidebarSkeleton />}>
<Sidebar /> {/* Async component */}
</Suspense>
<main>
<Suspense fallback={<ContentSkeleton />}>
{children}
</Suspense>
</main>
</div>
)
}💡 loading.tsx shows while data loads
⚡ Suspense for granular loading states
📌 Streaming allows progressive rendering
🟢 Skeleton UI improves perceived performance
Server Actions
Creating Server Actions
📄 Codetypescript
// app/actions.ts - Separate file for actions
'use server'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { z } from 'zod'
// Simple server action
export async function createPost(formData: FormData) {
const title = formData.get('title') as string
const content = formData.get('content') as string
// Validate input
if (!title || !content) {
throw new Error('Title and content are required')
}
// Save to database
const post = await db.post.create({
data: { title, content }
})
// Revalidate cache
revalidatePath('/posts')
// Redirect
redirect(`/posts/${post.id}`)
}
// With validation using Zod
const CreatePostSchema = z.object({
title: z.string().min(1).max(100),
content: z.string().min(10),
published: z.boolean().default(false),
})
export async function createPostValidated(formData: FormData) {
const validatedFields = CreatePostSchema.safeParse({
title: formData.get('title'),
content: formData.get('content'),
published: formData.get('published') === 'true',
})
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
}
}
const post = await db.post.create({
data: validatedFields.data
})
revalidatePath('/posts')
redirect(`/posts/${post.id}`)
}
// Inline server action in Server Component
async function ServerComponent() {
async function deletePost(id: string) {
'use server'
await db.post.delete({ where: { id } })
revalidatePath('/posts')
}
return <form action={deletePost}>...</form>
}💡 Functions that run on server, called from client
⚡ Replace API routes for mutations
📌 Use "use server" directive
🔥 Automatically handle form submissions
Using Server Actions in forms
📄 Codetypescript
// Simple form with Server Action
import { createPost } from '@/app/actions'
export default function CreatePostForm() {
return (
<form action={createPost}>
<input
type="text"
name="title"
placeholder="Post title"
required
/>
<textarea
name="content"
placeholder="Post content"
required
/>
<button type="submit">Create Post</button>
</form>
)
}
// With pending state
'use client'
import { useFormStatus } from 'react-dom'
import { createPost } from '@/app/actions'
function SubmitButton() {
const { pending } = useFormStatus()
return (
<button type="submit" disabled={pending}>
{pending ? 'Creating...' : 'Create Post'}
</button>
)
}
export default function FormWithStatus() {
return (
<form action={createPost}>
<input name="title" />
<textarea name="content" />
<SubmitButton />
</form>
)
}
// With form state and validation
'use client'
import { useActionState } from 'react'
import { createPostValidated } from '@/app/actions'
const initialState = {
errors: {},
message: null,
}
export default function FormWithValidation() {
const [state, formAction] = useActionState(createPostValidated, initialState)
return (
<form action={formAction}>
<div>
<input name="title" />
{state?.errors?.title && (
<p className="error">{state.errors.title}</p>
)}
</div>
<div>
<textarea name="content" />
{state?.errors?.content && (
<p className="error">{state.errors.content}</p>
)}
</div>
<button type="submit">Create Post</button>
</form>
)
}💡 Works with native form action attribute
⚡ Progressive enhancement without JS
📌 useFormStatus for pending states
🟢 useActionState for form state management
Client-side Server Actions
typescript
'use client'💡 Call Server Actions from Client Components
⚡ Use with onClick handlers
📌 Handle loading and error states
🔥 Optimistic updates for better UX
API Routes
Route handlers
📄 Codetypescript
// app/api/hello/route.ts
import { NextRequest, NextResponse } from 'next/server'
// GET request
export async function GET(request: NextRequest) {
return NextResponse.json({ message: 'Hello World' })
}
// POST request
export async function POST(request: NextRequest) {
const body = await request.json()
// Process the data
const result = await processData(body)
return NextResponse.json(result, { status: 201 })
}
// Other HTTP methods
export async function PUT(request: NextRequest) {
const body = await request.json()
return NextResponse.json({ updated: true })
}
export async function DELETE(request: NextRequest) {
return NextResponse.json({ deleted: true })
}
export async function PATCH(request: NextRequest) {
return NextResponse.json({ patched: true })
}
// With headers and cookies
export async function GET(request: NextRequest) {
// Read headers
const token = request.headers.get('authorization')
// Read cookies
const theme = request.cookies.get('theme')
// Set response headers
return NextResponse.json(
{ data: 'example' },
{
status: 200,
headers: {
'Set-Cookie': 'token=abc123; Path=/; HttpOnly',
'Cache-Control': 'no-cache',
},
}
)
}💡 Create API endpoints with route.ts files
⚡ Support all HTTP methods
📌 GET is dynamic (uncached) by default since v15; opt in via export const dynamic = force-static
🔥 Full access to Request/Response APIs
Dynamic route handlers
typescript
// app/api/posts/[id]/route.ts💡 Access route params in API routes
⚡ Search params available via URL
📌 Combine with database queries
🟢 Type-safe with TypeScript
Middleware
Creating middleware
📄 Codetypescript
// proxy.ts (Next.js 16 - was middleware.ts; in root or src/)
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function proxy(request: NextRequest) {
// Clone the request headers
const requestHeaders = new Headers(request.headers)
requestHeaders.set('x-custom-header', 'my-value')
// Create response with modified headers
const response = NextResponse.next({
request: {
headers: requestHeaders,
},
})
// Set response headers
response.headers.set('x-response-header', 'value')
return response
}
// Configure which paths proxy runs on
export const config = {
matcher: [
// Match all paths except static files and api
'/((?!api|_next/static|_next/image|favicon.ico).*)',
// Or specific paths
'/dashboard/:path*',
'/api/:path*',
],
}💡 proxy runs before every request - nodejs runtime, no edge
⚡ Place proxy.ts (was middleware.ts) in root or src/
📌 Can modify request/response
🟢 Use for auth, redirects, headers
Authentication middleware
typescript
// proxy.ts (Next.js 16 - was middleware.ts) - Authentication example💡 Protect routes with auth checks
⚡ Redirect unauthenticated users
📌 Work with JWT tokens
🔥 Integration with auth libraries
Image Optimization
Next.js Image component
📄 Codetypescript
import Image from 'next/image'
// Basic usage with local image
import heroImg from '@/public/hero.jpg'
export default function Hero() {
return (
<Image
src={heroImg}
alt="Hero image"
// width and height are auto-detected for local images
priority // Load eagerly (for above-fold images)
/>
)
}
// Remote images need dimensions
export function RemoteImage() {
return (
<Image
src="https://example.com/photo.jpg"
alt="Remote photo"
width={800}
height={600}
className="rounded-lg"
/>
)
}
// Responsive with fill
export function ResponsiveImage() {
return (
<div className="relative w-full h-[400px]">
<Image
src="/banner.jpg"
alt="Banner"
fill
style={{ objectFit: 'cover' }}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>
</div>
)
}
// With blur placeholder
import myImage from '@/public/photo.jpg'
export function BlurImage() {
return (
<Image
src={myImage}
alt="Photo"
placeholder="blur" // Auto-generated for local images
// blurDataURL={customBlurDataURL} // For remote images
/>
)
}💡 Automatic optimization and lazy loading
⚡ Responsive images with srcset
📌 Prevents layout shift with dimensions
🔥 Supports blur placeholder
Deployment
Vercel deployment
📄 Codebash
# Install Vercel CLI
npm i -g vercel
# Deploy to Vercel
vercel
# Deploy to production
vercel --prod
# Link to existing project
vercel link
# Set environment variables
vercel env add DATABASE_URL
# View deployment logs
vercel logs
# Set up domains
vercel domains add example.com💡 Zero-config deployment for Next.js
⚡ Automatic CI/CD from Git
📌 Preview deployments for PRs
🟢 Edge functions and analytics
Environment variables
📄 Codetypescript
// .env.local (git ignored)
DATABASE_URL=postgresql://localhost:5432/myapp
API_SECRET=secret_key_here
// Public variables (exposed to client)
NEXT_PUBLIC_API_URL=https://api.example.com
NEXT_PUBLIC_GA_ID=G-XXXXXXXXXX
// env.ts - Type-safe environment variables
import { z } from 'zod'
const envSchema = z.object({
// Server-side variables
DATABASE_URL: z.string().url(),
API_SECRET: z.string().min(1),
NODE_ENV: z.enum(['development', 'production', 'test']),
// Client-side variables
NEXT_PUBLIC_API_URL: z.string().url(),
NEXT_PUBLIC_GA_ID: z.string().optional(),
})
// Validate at build time
const env = envSchema.parse({
DATABASE_URL: process.env.DATABASE_URL,
API_SECRET: process.env.API_SECRET,
NODE_ENV: process.env.NODE_ENV,
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
NEXT_PUBLIC_GA_ID: process.env.NEXT_PUBLIC_GA_ID,
})
export default env
// Usage in server components
import env from '@/lib/env'
async function getData() {
const response = await fetch(env.NEXT_PUBLIC_API_URL + '/data', {
headers: {
'Authorization': `Bearer ${env.API_SECRET}`
}
})
return response.json()
}💡 .env.local for local development
⚡ NEXT_PUBLIC_ prefix for client-side
📌 Runtime vs build-time variables
🟢 Validation with zod or t3-env
Performance Optimization
Bundle optimization
📄 Codetypescript
// next.config.js - Bundle analyzer
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
})
module.exports = withBundleAnalyzer({
// Your config
})
// Run: ANALYZE=true npm run build
// Dynamic imports for code splitting
import dynamic from 'next/dynamic'
// Lazy load heavy component
const HeavyChart = dynamic(() => import('@/components/HeavyChart'), {
loading: () => <div>Loading chart...</div>,
ssr: false, // Disable SSR for client-only components
})
// Conditional loading
export function Dashboard() {
const [showChart, setShowChart] = useState(false)
return (
<div>
<button onClick={() => setShowChart(true)}>
Show Chart
</button>
{showChart && <HeavyChart />}
</div>
)
}
// Dynamic import in event handler
export function MyComponent() {
const handleClick = async () => {
const { processData } = await import('@/lib/heavy-processing')
const result = await processData()
console.log(result)
}
return <button onClick={handleClick}>Process</button>
}💡 Analyze bundle with @next/bundle-analyzer
⚡ Code splitting happens automatically
📌 Dynamic imports for lazy loading
🔥 Tree shaking removes unused code
Advanced Routing & Modern Features
Parallel/Intercepting routes, PPR, instrumentation, after(), revalidation
Parallel Routes (@slots)
Render multiple pages in the same layout in parallel
tsx
// app/dashboard/layout.tsx
export default function Layout({
children,
team,
analytics,
}: {
children: React.ReactNode
team: React.ReactNode
analytics: React.ReactNode
}) {
return (
<>
{children}
<div className="grid grid-cols-2">
{team}
{analytics}
</div>
</>
)
}
// app/dashboard/@team/page.tsx → renders into {team}
// app/dashboard/@analytics/page.tsx → renders into {analytics}
// app/dashboard/page.tsx → renders into {children}💡 Each slot is an independent route tree with its own loading/error
📌 default.tsx is required on slots once you start navigating — else 404
⚡ Great for dashboards: independent loading states per panel
🎯 Pair with intercepting routes to ship URL-addressable modals
routingapp-routermodern
Intercepting Routes (Modals)
Catch a route inside the current layout — perfect for modals
tsx
// app/feed/page.tsx
<Link href="/photo/123">Open</Link>
// app/photo/[id]/page.tsx ← full page on hard nav / refresh
// app/feed/(..)photo/[id]/page.tsx ← modal on client nav from /feed
export default function PhotoModal({ params }: { params: { id: string } }) {
return (
<Modal>
<Photo id={params.id} />
</Modal>
)
}💡 Same URL renders as modal from /feed and full page on refresh
⚡ (.), (..), (..)(..), (...) are the segment-level interception matchers
📌 Almost always paired with a parallel @modal slot for clean layering
🎯 Shareable + bookmarkable modals — the killer Next.js routing pattern
routingmodalsapp-router
Partial Prerendering (PPR)
Static shell + streamed dynamic holes in the same response
tsx
// next.config.ts (Next.js 16 - PPR via Cache Components)
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true, // replaces experimental.ppr / dynamicIO / useCache
}
export default nextConfig
// app/product/[id]/page.tsx (no per-route flag needed in 16)
import { Suspense } from 'react'
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
return (
<main>
<ProductDetails id={id} /> {/* prerendered shell */}
<Suspense fallback={<CartFallback />}>
<Cart /> {/* dynamic hole, streamed in */}
</Suspense>
</main>
)
}💡 Static shell from the CDN + dynamic holes streamed in one response
📌 Requires <Suspense> around every dynamic chunk - else falls back to full dynamic
⚡ Enabled by cacheComponents: true in 16 (experimental_ppr was removed)
🎯 Killer for product pages, dashboards, anywhere you mix shared + personal UI
pprperformanceexperimental
instrumentation.ts
Hook into the Node/edge runtime — register OTel, init clients, log errors
typescript
// instrumentation.ts (project root, sibling to next.config.ts)
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
await import('./instrumentation-node')
}
}
export async function onRequestError(
err: unknown,
request: { path: string; method: string },
context: { routerKind: 'Pages Router' | 'App Router' }
) {
await fetch('https://logs.example.com/ingest', {
method: 'POST',
body: JSON.stringify({ err: String(err), request, context }),
})
}💡 register() runs once per process — perfect for OTel/Sentry/DB warmup
📌 onRequestError catches unhandled errors across RSC, actions, handlers, middleware
⚡ Split Node vs Edge with NEXT_RUNTIME for runtime-specific deps
🎯 Pair with `@vercel/otel` for a one-line OpenTelemetry setup
observabilitymodern
after() & Revalidation Helpers
Run work after the response + cache invalidation API
typescript
import { after } from 'next/server'
import { revalidateTag, revalidatePath } from 'next/cache'
export async function POST(req: Request) {
const data = await req.json()
const post = await db.post.create({ data })
// Work that should NOT block the response:
after(async () => {
await sendEmail(post.authorEmail, 'Your post is live')
await track('post.created', { id: post.id })
})
// Targeted cache invalidation:
revalidateTag(`posts:author:${post.authorId}`)
revalidatePath('/feed')
return Response.json(post)
}💡 after() runs callbacks post-response — no impact on user latency
📌 revalidateTag pairs with `fetch(url, { next: { tags: [...] } })`
⚡ revalidatePath busts a page/layout; revalidateTag is surgical
🎯 Use after() for emails, analytics, and webhook fan-out after mutations
cachingserver-actionsmodern