Next.js Data Fetching
Next.js provides several data fetching strategies that leverage the server for faster, more secure, and more efficient data loading. In the App Router, you fetch data directly in Server Components using async/await. Combined with built-in caching and revalidation, this eliminates the need for client-side state management for most data needs.
The key principle: fetch data as close to where it is used as possible. Server Components can fetch data at the component level, enabling fine-grained loading states and eliminating waterfall problems through automatic deduplication.
info
Server Components can be async functions. Use async/await to fetch data directly โ no useEffect, no useState, no loading state management.
| 1 | // app/products/page.tsx |
| 2 | import { db } from "@/lib/database"; |
| 3 | |
| 4 | export default async function ProductsPage() { |
| 5 | // Direct database query โ no API endpoint needed |
| 6 | const products = await db.product.findMany({ |
| 7 | orderBy: { createdAt: "desc" }, |
| 8 | take: 20, |
| 9 | }); |
| 10 | |
| 11 | return ( |
| 12 | <div> |
| 13 | <h1>Products ({products.length})</h1> |
| 14 | <div className="grid grid-cols-3 gap-4"> |
| 15 | {products.map(product => ( |
| 16 | <div key={product.id} className="border rounded-lg p-4"> |
| 17 | <h2 className="font-mono">{product.name}</h2> |
| 18 | <p className="text-sm text-gray-500">{product.description}</p> |
| 19 | <p className="text-lg font-mono mt-2">${product.price}</p> |
| 20 | </div> |
| 21 | ))} |
| 22 | </div> |
| 23 | </div> |
| 24 | ); |
| 25 | } |
You can also use the native fetch API for external HTTP requests. Next.js extends fetch with caching and revalidation options.
| 1 | // app/posts/page.tsx |
| 2 | |
| 3 | async function getPosts() { |
| 4 | const res = await fetch("https://api.example.com/posts", { |
| 5 | // Cache options (extended by Next.js) |
| 6 | next: { revalidate: 3600 }, // Revalidate every hour |
| 7 | }); |
| 8 | |
| 9 | if (!res.ok) throw new Error("Failed to fetch posts"); |
| 10 | return res.json(); |
| 11 | } |
| 12 | |
| 13 | export default async function PostsPage() { |
| 14 | const posts = await getPosts(); |
| 15 | |
| 16 | return ( |
| 17 | <div> |
| 18 | <h1>Posts</h1> |
| 19 | {posts.map(post => ( |
| 20 | <article key={post.id} className="mb-4"> |
| 21 | <h2>{post.title}</h2> |
| 22 | <p>{post.body}</p> |
| 23 | </article> |
| 24 | ))} |
| 25 | </div> |
| 26 | ); |
| 27 | } |
Next.js provides several caching mechanisms to control how data is stored and refreshed.
Request Memoization
During a single server render, if multiple components call fetch with the same URL and options, the requests are automatically deduplicated. Only one HTTP request is made.
| 1 | // These two components make the same fetch |
| 2 | // Only ONE HTTP request is made during the render |
| 3 | |
| 4 | // app/components/user-info.tsx |
| 5 | async function UserInfo({ userId }: { userId: string }) { |
| 6 | const user = await fetch(`https://api.example.com/users/${userId}`).then(r => r.json()); |
| 7 | return <p>{user.name}</p>; |
| 8 | } |
| 9 | |
| 10 | // app/components/user-avatar.tsx |
| 11 | async function UserAvatar({ userId }: { userId: string }) { |
| 12 | const user = await fetch(`https://api.example.com/users/${userId}`).then(r => r.json()); |
| 13 | return <img src={user.avatar} alt={user.name} />; |
| 14 | } |
Data Cache (Persistent)
Responses from fetch are cached persistently across requests and server restarts. The cache is stored on the filesystem and can be shared across deployments.
| 1 | // Cached by default (GET requests) |
| 2 | const res = await fetch("https://api.example.com/data"); |
| 3 | // This response is cached until explicitly revalidated |
| 4 | |
| 5 | // Opt out of cache |
| 6 | const res = await fetch("https://api.example.com/data", { |
| 7 | cache: "no-store", // Never cache this response |
| 8 | }); |
| 9 | |
| 10 | // Cache with tags for on-demand revalidation |
| 11 | const res = await fetch("https://api.example.com/data", { |
| 12 | next: { tags: ["data", "products"] }, |
| 13 | }); |
Full Route Cache
During the build, Next.js caches the rendered output of static routes. This means the route is pre-rendered once and served from the CDN on every request. This is the default for routes without dynamic APIs.
| 1 | // This route is statically cached at build time |
| 2 | // No dynamic APIs used โ automatic static rendering |
| 3 | export default async function AboutPage() { |
| 4 | const team = await getTeamMembers(); // Fetched at build time |
| 5 | return ( |
| 6 | <div> |
| 7 | <h1>About Us</h1> |
| 8 | {team.map(member => ( |
| 9 | <div key={member.id}>{member.name}</div> |
| 10 | ))} |
| 11 | </div> |
| 12 | ); |
| 13 | } |
Router Cache (Client-Side)
On the client, Next.js caches prefetched routes and their RSC payloads. This makes client-side navigation nearly instant for previously visited routes.
| 1 | // The Link component prefetches by default |
| 2 | import Link from "next/link"; |
| 3 | |
| 4 | // Prefetch is enabled by default (for static routes) |
| 5 | <Link href="/blog">Blog</Link> |
| 6 | |
| 7 | // Disable prefetch for dynamic or rarely-visited routes |
| 8 | <Link href="/dashboard/analytics" prefetch={false}> |
| 9 | Analytics |
| 10 | </Link> |
| 11 | |
| 12 | // router.refresh() invalidates the client cache |
| 13 | "use client"; |
| 14 | import { useRouter } from "next/navigation"; |
| 15 | |
| 16 | export function RefreshButton() { |
| 17 | const router = useRouter(); |
| 18 | return <button onClick={() => router.refresh()}>Refresh Data</button>; |
| 19 | } |
Revalidation controls when cached data is refreshed. Next.js supports both time-based (ISR) and on-demand revalidation.
Time-Based Revalidation (ISR)
Set a revalidation interval in seconds. After the interval, the next request will serve the stale response while revalidating in the background. Once the revalidation completes, subsequent requests receive the fresh data.
| 1 | // app/products/page.tsx |
| 2 | // Revalidate this route every 60 seconds |
| 3 | |
| 4 | export const revalidate = 60; |
| 5 | |
| 6 | export default async function ProductsPage() { |
| 7 | const products = await fetch("https://api.example.com/products", { |
| 8 | next: { revalidate: 60 }, |
| 9 | }).then(r => r.json()); |
| 10 | |
| 11 | return ( |
| 12 | <div> |
| 13 | <h1>Products</h1> |
| 14 | <p className="text-xs text-gray-500"> |
| 15 | Last updated: {new Date().toISOString()} |
| 16 | </p> |
| 17 | {products.map(product => ( |
| 18 | <div key={product.id}>{product.name}</div> |
| 19 | ))} |
| 20 | </div> |
| 21 | ); |
| 22 | } |
On-Demand Revalidation
Revalidate specific routes or tags when data changes. This is triggered programmatically โ typically from a Server Action or API route when content is updated.
| 1 | // app/actions/revalidate.ts |
| 2 | "use server"; |
| 3 | |
| 4 | import { revalidatePath, revalidateTag } from "next/cache"; |
| 5 | |
| 6 | export async function revalidateProducts() { |
| 7 | // Revalidate all routes under /products |
| 8 | revalidatePath("/products"); |
| 9 | } |
| 10 | |
| 11 | export async function revalidateProduct(productId: string) { |
| 12 | // Revalidate a specific product page |
| 13 | revalidatePath(`/products/${productId}`); |
| 14 | } |
| 15 | |
| 16 | export async function revalidateAllProducts() { |
| 17 | // Revalidate all fetches with the "products" tag |
| 18 | revalidateTag("products"); |
| 19 | } |
| 1 | // Tag-based fetch (works with revalidateTag) |
| 2 | async function getProducts() { |
| 3 | const res = await fetch("https://api.example.com/products", { |
| 4 | next: { tags: ["products"] }, |
| 5 | }); |
| 6 | return res.json(); |
| 7 | } |
| 8 | |
| 9 | // API route for webhook-based revalidation |
| 10 | // app/api/revalidate/route.ts |
| 11 | import { revalidateTag } from "next/cache"; |
| 12 | import { NextRequest, NextResponse } from "next/server"; |
| 13 | |
| 14 | export async function POST(request: NextRequest) { |
| 15 | const { tag, secret } = await request.json(); |
| 16 | |
| 17 | if (secret !== process.env.REVALIDATION_SECRET) { |
| 18 | return NextResponse.json({ error: "Invalid secret" }, { status: 401 }); |
| 19 | } |
| 20 | |
| 21 | revalidateTag(tag); |
| 22 | return NextResponse.json({ revalidated: true, now: Date.now() }); |
| 23 | } |
info
ISR lets you update static pages after you've built your site. Instead of rebuilding the entire site when content changes, individual pages are regenerated in the background when their revalidation interval expires.
| 1 | // app/blog/[slug]/page.tsx |
| 2 | import { db } from "@/lib/database"; |
| 3 | import { notFound } from "next/navigation"; |
| 4 | |
| 5 | // Generate popular blog posts at build time |
| 6 | export async function generateStaticParams() { |
| 7 | const posts = await db.post.findMany({ |
| 8 | orderBy: { views: "desc" }, |
| 9 | take: 100, // Pre-render top 100 posts |
| 10 | }); |
| 11 | return posts.map(post => ({ slug: post.slug })); |
| 12 | } |
| 13 | |
| 14 | // Revalidate each post page individually |
| 15 | export const revalidate = 3600; // Revalidate every hour |
| 16 | |
| 17 | export async function generateMetadata({ |
| 18 | params, |
| 19 | }: { |
| 20 | params: Promise<{ slug: string }>; |
| 21 | }) { |
| 22 | const { slug } = await params; |
| 23 | const post = await db.post.findUnique({ where: { slug } }); |
| 24 | if (!post) return { title: "Not Found" }; |
| 25 | |
| 26 | return { |
| 27 | title: post.title, |
| 28 | description: post.excerpt, |
| 29 | openGraph: { title: post.title, images: [post.coverImage] }, |
| 30 | }; |
| 31 | } |
| 32 | |
| 33 | export default async function BlogPost({ |
| 34 | params, |
| 35 | }: { |
| 36 | params: Promise<{ slug: string }>; |
| 37 | }) { |
| 38 | const { slug } = await params; |
| 39 | const post = await db.post.findUnique({ where: { slug } }); |
| 40 | |
| 41 | if (!post) notFound(); |
| 42 | |
| 43 | return ( |
| 44 | <article className="max-w-2xl mx-auto"> |
| 45 | <h1 className="text-3xl font-mono mb-4">{post.title}</h1> |
| 46 | <time className="text-sm text-gray-500"> |
| 47 | {post.createdAt.toDateString()} |
| 48 | </time> |
| 49 | <div className="mt-6 prose">{post.content}</div> |
| 50 | </article> |
| 51 | ); |
| 52 | } |
pro tip
Certain APIs force dynamic rendering. When used, the route segment becomes dynamic โ rendered at request time instead of build time.
| API | Purpose | Force Dynamic |
|---|---|---|
| cookies() | Read/write cookies | Yes |
| headers() | Read request headers | Yes |
| searchParams | Read URL query parameters | Yes |
| dynamicParams | Control unmatched dynamic segments | No (configurable) |
| 1 | // Force dynamic rendering for user-specific pages |
| 2 | import { cookies, headers } from "next/headers"; |
| 3 | |
| 4 | export default async function DashboardPage() { |
| 5 | const cookieStore = await cookies(); |
| 6 | const headerStore = await headers(); |
| 7 | |
| 8 | const session = cookieStore.get("session"); |
| 9 | const userAgent = headerStore.get("user-agent"); |
| 10 | |
| 11 | if (!session) { |
| 12 | return <p>Please log in to access the dashboard.</p>; |
| 13 | } |
| 14 | |
| 15 | // This data is fetched at request time, not build time |
| 16 | const userData = await getUserData(session.value); |
| 17 | |
| 18 | return ( |
| 19 | <div> |
| 20 | <h1>Welcome, {userData.name}</h1> |
| 21 | <p>User Agent: {userAgent}</p> |
| 22 | </div> |
| 23 | ); |
| 24 | } |
| 1 | // Explicitly force dynamic for the whole route segment |
| 2 | export const dynamic = "force-dynamic"; |
| 3 | |
| 4 | // Or force static |
| 5 | export const dynamic = "force-static"; |
| 6 | |
| 7 | // Control unmatched params for dynamic segments |
| 8 | // app/shop/[...slug]/page.tsx |
| 9 | export const dynamicParams = true; // true = render on-demand (default) |
| 10 | // export const dynamicParams = false; // false = 404 for unmatched params |
How you structure data fetching affects performance. Parallel fetching eliminates waterfalls, while sequential fetching is needed when one request depends on another.
| 1 | // Parallel fetching โ both requests run simultaneously |
| 2 | export default async function DashboardPage() { |
| 3 | // โ Sequential โ waterfall (slow) |
| 4 | const user = await fetchUser(); // 2 seconds |
| 5 | const posts = await fetchPosts(); // 2 seconds |
| 6 | // Total: 4 seconds |
| 7 | |
| 8 | // โ Parallel โ no waterfall (fast) |
| 9 | const [user, posts] = await Promise.all([ |
| 10 | fetchUser(), // 2 seconds (runs in parallel) |
| 11 | fetchPosts(), // 2 seconds (runs in parallel) |
| 12 | ]); |
| 13 | // Total: 2 seconds |
| 14 | |
| 15 | return ( |
| 16 | <div> |
| 17 | <UserCard user={user} /> |
| 18 | <PostList posts={posts} /> |
| 19 | </div> |
| 20 | ); |
| 21 | } |
| 1 | // Sequential when dependent |
| 2 | export default async function OrderPage({ |
| 3 | params, |
| 4 | }: { |
| 5 | params: Promise<{ id: string }>; |
| 6 | }) { |
| 7 | const { id } = await params; |
| 8 | |
| 9 | // Step 1: Fetch order (need order data to know user) |
| 10 | const order = await fetchOrder(id); |
| 11 | |
| 12 | // Step 2: Fetch user (depends on order.userId) |
| 13 | const user = await fetchUser(order.userId); |
| 14 | |
| 15 | // Step 3: Fetch recommendations (depends on user preferences) |
| 16 | const recommendations = await fetchRecommendations(user.preferences); |
| 17 | |
| 18 | return ( |
| 19 | <div> |
| 20 | <OrderDetails order={order} user={user} /> |
| 21 | <Recommendations items={recommendations} /> |
| 22 | </div> |
| 23 | ); |
| 24 | } |
info
Combine Suspense with async Server Components to stream data progressively. Fast components render in the shell immediately while slow components stream in as their data resolves.
| 1 | // app/dashboard/page.tsx |
| 2 | import { Suspense } from "react"; |
| 3 | |
| 4 | async function RevenueChart() { |
| 5 | // Takes 3 seconds |
| 6 | const data = await fetchRevenueData(); |
| 7 | return <Chart data={data} />; |
| 8 | } |
| 9 | |
| 10 | async function RecentSales() { |
| 11 | // Takes 1 second |
| 12 | const sales = await fetchRecentSales(); |
| 13 | return <SalesList sales={sales} />; |
| 14 | } |
| 15 | |
| 16 | export default function DashboardPage() { |
| 17 | return ( |
| 18 | <div> |
| 19 | <h1>Dashboard</h1> |
| 20 | |
| 21 | {/* Fast โ appears immediately */} |
| 22 | <div className="grid grid-cols-4 gap-4"> |
| 23 | <StatCard title="Revenue" value="$12,345" /> |
| 24 | <StatCard title="Orders" value="234" /> |
| 25 | <StatCard title="Customers" value="1,234" /> |
| 26 | <StatCard title="Conversion" value="3.2%" /> |
| 27 | </div> |
| 28 | |
| 29 | {/* Slow โ streams in with loading skeleton */} |
| 30 | <Suspense fallback={<ChartSkeleton />}> |
| 31 | <RevenueChart /> |
| 32 | </Suspense> |
| 33 | |
| 34 | {/* Medium โ streams in independently */} |
| 35 | <Suspense fallback={<SalesSkeleton />}> |
| 36 | <RecentSales /> |
| 37 | </Suspense> |
| 38 | </div> |
| 39 | ); |
| 40 | } |
Handle fetch errors gracefully with try/catch in Server Components and error boundaries at the route level.
| 1 | // app/posts/page.tsx |
| 2 | import { Suspense } from "react"; |
| 3 | import { ErrorBoundary } from "@/components/error-boundary"; |
| 4 | |
| 5 | async function PostList() { |
| 6 | try { |
| 7 | const posts = await fetch("https://api.example.com/posts", { |
| 8 | next: { revalidate: 3600 }, |
| 9 | }); |
| 10 | |
| 11 | if (!posts.ok) { |
| 12 | throw new Error(`HTTP ${posts.status}: ${posts.statusText}`); |
| 13 | } |
| 14 | |
| 15 | const data = await posts.json(); |
| 16 | return data.map(post => ( |
| 17 | <article key={post.id} className="mb-4"> |
| 18 | <h2>{post.title}</h2> |
| 19 | <p>{post.body}</p> |
| 20 | </article> |
| 21 | )); |
| 22 | } catch (error) { |
| 23 | return ( |
| 24 | <div className="p-4 border border-red-500/20 rounded"> |
| 25 | <p className="text-red-400 text-sm"> |
| 26 | Failed to load posts. Please try again later. |
| 27 | </p> |
| 28 | </div> |
| 29 | ); |
| 30 | } |
| 31 | } |
| 32 | |
| 33 | export default function PostsPage() { |
| 34 | return ( |
| 35 | <div> |
| 36 | <h1>Posts</h1> |
| 37 | <Suspense fallback={<div className="animate-pulse">Loading posts...</div>}> |
| 38 | <PostList /> |
| 39 | </Suspense> |
| 40 | </div> |
| 41 | ); |
| 42 | } |
warning