Next.js Data Fetching
Targeting Next.js 16 (App Router)
Syntax
Server Component Fetch
// app/users/page.tsx — server component by default
export default async function UsersPage() {
const res = await fetch('https://api.example.com/users')
const users = await res.json()
return <ul>{users.map((u: User) => (
<li key={u.id}>{u.name}</li>
))}</ul>
}
// no useEffect, no loading state — just async/awaitDirect DB Query
// server components can query the DB directly
import { db } from '@/lib/db'
export default async function Posts() {
const posts = await db.post.findMany({
orderBy: { createdAt: 'desc' },
})
return posts.map((p) => <Card key={p.id} post={p} />)
}
// no API route needed — runs on the serverStatic vs Dynamic
// STATIC — fetched at build time, cached forever
const res = await fetch('https://api.example.com/posts')
// REVALIDATED — cached, refreshed every 60s
const res = await fetch('https://api.example.com/posts', {
next: { revalidate: 60 },
})
// DYNAMIC — fresh every request
const res = await fetch('https://api.example.com/posts', {
cache: 'no-store',
})Page-Level Config
// force every fetch on this page to be dynamic
export const dynamic = 'force-dynamic'
// or revalidate all data every N seconds
export const revalidate = 60
// or force static (errors if dynamic APIs are used)
export const dynamic = 'force-static'Params & Search Params
interface Props {
params: Promise<{ id: string }>
searchParams: Promise<{ sort?: string }>
}
export default async function Page({ params, searchParams }: Props) {
const { id } = await params
const { sort } = await searchParams
const user = await getUser(id)
const posts = await getPosts({ userId: id, sort })
return <Profile user={user} posts={posts} />
}Loading State
// app/dashboard/loading.tsx
export default function Loading() {
return <Skeleton />
}
// Next wraps page.tsx in <Suspense fallback={<Loading />}>
// shown while async page component resolvesBeginner Patterns
Fetch with Error
export default async function Page() {
const res = await fetch('https://api.example.com/data')
if (!res.ok) {
throw new Error('Failed to fetch')
// caught by nearest error.tsx
}
const data = await res.json()
return <pre>{JSON.stringify(data)}</pre>
}Parallel Fetches
export default async function Dashboard() {
// fire both at once — don't await sequentially
const [users, posts] = await Promise.all([
getUsers(),
getPosts(),
])
return (
<>
<UserList users={users} />
<PostFeed posts={posts} />
</>
)
}Fetch Helper
// lib/api.ts
export async function api<T>(path: string): Promise<T> {
const res = await fetch(`${process.env.API_URL}${path}`)
if (!res.ok) throw new Error(`API error: ${res.status}`)
return res.json()
}
// usage in server component
const users = await api<User[]>('/users')Suspense Boundaries
import { Suspense } from 'react'
export default function Page() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<p>Loading stats...</p>}>
<Stats /> {/* async server component */}
</Suspense>
<Suspense fallback={<p>Loading feed...</p>}>
<Feed /> {/* streams independently */}
</Suspense>
</div>
)
}Client-Side Fetch
'use client'
import { useEffect, useState } from 'react'
export function LivePrice() {
const [price, setPrice] = useState<number | null>(null)
useEffect(() => {
const id = setInterval(async () => {
const res = await fetch('/api/price')
const { price } = await res.json()
setPrice(price)
}, 5000)
return () => clearInterval(id)
}, [])
return <span>{price ?? '...'}</span>
}notFound()
import { notFound } from 'next/navigation'
export default async function UserPage({ params }: Props) {
const { id } = await params
const user = await getUser(id)
if (!user) notFound() // renders not-found.tsx
return <h1>{user.name}</h1>
}Intermediate Patterns
cache() Dedup
import { cache } from 'react'
// deduplicate across components in one render
export const getUser = cache(async (id: string) => {
const res = await fetch(`/api/users/${id}`)
return res.json()
})
// both components call getUser('1') —
// only one fetch fires per requestcacheLife + cacheTag
import { cacheLife, cacheTag } from 'next/cache'
// stable in v16 — no more unstable_ prefix
async function getPosts() {
'use cache'
cacheLife('hours') // built-in profile: hours
cacheTag('posts') // tag for revalidation
return db.post.findMany()
}
const posts = await getPosts()Tag-Based Revalidation
// tag the fetch
const res = await fetch('https://api.example.com/posts', {
next: { tags: ['posts'] },
})
// revalidate by tag — stale-while-revalidate
import { revalidateTag } from 'next/cache'
revalidateTag('posts') // default behavior
revalidateTag('posts', 'max') // v16: optional cacheLife profile
// updateTag — read-your-writes (user sees change immediately)
import { updateTag } from 'next/cache'
updateTag('posts') // expire + refresh in same requestPath Revalidation
import { revalidatePath } from 'next/cache'
// revalidate a specific page
revalidatePath('/blog')
// revalidate a dynamic route
revalidatePath('/blog/[slug]', 'page')
// revalidate everything under a layout
revalidatePath('/dashboard', 'layout')generateStaticParams
// pre-render known pages at build time
export async function generateStaticParams() {
const posts = await getPosts()
return posts.map((p) => ({ slug: p.slug }))
}
// combined with revalidate for ISR
export const revalidate = 3600 // re-gen every hourStreaming with Suspense
// slow data doesn't block the whole page
export default async function Page() {
const fast = await getFast() // resolves quickly
return (
<div>
<Header data={fast} />
<Suspense fallback={<Skeleton />}>
<SlowSection /> {/* streams in when ready */}
</Suspense>
</div>
)
}
// HTML shell sent immediately, slow part streams laterAdvanced Patterns
Route Handler GET
// app/api/posts/route.ts
import { NextResponse } from 'next/server'
export async function GET(req: Request) {
const { searchParams } = new URL(req.url)
const page = Number(searchParams.get('page') ?? 1)
const posts = await db.post.findMany({
skip: (page - 1) * 10,
take: 10,
})
return NextResponse.json(posts)
}On-Demand ISR
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache'
import { NextResponse } from 'next/server'
export async function POST(req: Request) {
const { tag, secret } = await req.json()
if (secret !== process.env.REVALIDATE_SECRET) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
revalidateTag(tag)
return NextResponse.json({ revalidated: true })
}
// call from CMS webhook to bust cacheWaterfall Prevention
// BAD — sequential (waterfall)
const user = await getUser(id)
const posts = await getPosts(user.id)
const comments = await getComments(posts[0].id)
// GOOD — parallel where possible
const user = await getUser(id)
const [posts, followers] = await Promise.all([
getPosts(user.id),
getFollowers(user.id),
])Preload Pattern
// lib/data.ts
import { cache } from 'react'
export const getUser = cache(async (id: string) => {
return db.user.findUnique({ where: { id } })
})
// call early in the tree to start fetching
export function preloadUser(id: string) {
void getUser(id)
}
// layout.tsx
export default function Layout({ params }: Props) {
preloadUser(params.id) // starts fetch
return <>{children}</> // child calls getUser — instant
}Fetch with Headers
import { cookies, headers } from 'next/headers'
export default async function Page() {
const token = (await cookies()).get('token')?.value
const ua = (await headers()).get('user-agent')
const res = await fetch('https://api.example.com/me', {
headers: { Authorization: `Bearer ${token}` },
cache: 'no-store', // dynamic — depends on cookies
})
const user = await res.json()
return <p>{user.name}</p>
}SWR for Client Data
'use client'
import useSWR from 'swr'
const fetcher = (url: string) =>
fetch(url).then((r) => r.json())
function Notifications() {
const { data, error, isLoading } = useSWR(
'/api/notifications',
fetcher,
{ refreshInterval: 10_000 } // poll every 10s
)
if (isLoading) return <Spinner />
if (error) return <p>Error</p>
return <Badge count={data.length} />
}Last updated on