Skip to Content
Grab N GoNext.js Data Fetching

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/await
Direct 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 server
Static 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 resolves

Beginner 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 request
cacheLife + 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 request
Path 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 hour
Streaming 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 later

Advanced 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 cache
Waterfall 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