Data Fetching Strategies in Next.js
Compare Next.js data fetching strategies including static generation, server-side rendering, and incremental static regeneration, with guidance on when to us...
Introduction
One of Next.js's biggest strengths is the range of data fetching strategies it supports, letting you choose the right rendering behavior for every route: build once and serve forever, regenerate periodically, or render fresh on every request. Picking the wrong strategy either wastes server resources or serves stale data to users, so understanding the trade-offs matters as much as knowing the syntax.
Static Generation: Build Once, Serve Everywhere
Static generation renders a page's HTML at build time. The result is served from a CDN edge cache, making it about as fast as content can be delivered:
// app/blog/[slug]/page.js
async function getPost(slug) {
const res = await fetch(`https://api.example.com/posts/${slug}`);
return res.json();
}
export default async function BlogPost({ params }) {
const post = await getPost(params.slug);
return <article>{post.content}</article>;
}
By default, fetch calls inside Server Components are cached, which is what makes this page static: Next.js fetches the data once at build time and reuses the cached HTML for every visitor until that cache is invalidated.
generateStaticParams: Pre-rendering Dynamic Routes
For dynamic routes like [slug], generateStaticParams tells Next.js which specific pages to pre-render at build time:
export async function generateStaticParams() {
const posts = await fetch("https://api.example.com/posts").then((r) => r.json());
return posts.map((post) => ({ slug: post.slug }));
}
Any slug not returned here can still be rendered on first request and cached afterward, depending on your configuration.
Server-Side Rendering: Fresh Data on Every Request
Some pages genuinely need to reflect the exact current state on every visit — a live dashboard, search results, or anything personalized per user. Opting out of caching makes the route dynamic:
async function getLiveOrders() {
const res = await fetch("https://api.example.com/orders", { cache: "no-store" });
return res.json();
}
export default async function OrdersPage() {
const orders = await getLiveOrders();
return <OrdersTable orders={orders} />;
}
Using dynamic functions like reading cookies or headers, or setting { cache: "no-store" }, tells Next.js this route cannot be pre-rendered and must run fresh on the server for every request.
Incremental Static Regeneration: The Middle Ground
ISR combines the speed of static pages with periodically fresh data, by revalidating the cache after a specified number of seconds:
async function getProducts() {
const res = await fetch("https://api.example.com/products", {
next: { revalidate: 60 }, // regenerate at most once every 60 seconds
});
return res.json();
}
export default async function ProductsPage() {
const products = await getProducts();
return <ProductGrid products={products} />;
}
The first request after the revalidation window still receives the (slightly stale) cached page instantly, while Next.js regenerates the page in the background for subsequent visitors — a pattern known as stale-while-revalidate.
On-Demand Revalidation
Rather than waiting for a timer, you can invalidate specific cached content immediately after a mutation, such as publishing a new blog post:
"use server";
import { revalidatePath } from "next/cache";
export async function publishPost(formData) {
await savePostToDatabase(formData);
revalidatePath("/blog");
}
revalidateTag works similarly but targets fetch calls tagged with a specific string, which is useful when many different routes depend on the same underlying data.
Client-Side Fetching for Highly Interactive Data
Not everything belongs in a Server Component. Data that depends on client-only state — search-as-you-type results, live polling, or anything gated behind client-side interaction — is often fetched from a Client Component, typically with a data-fetching library like React Query or SWR that handles caching and revalidation in the browser:
"use client";
import useSWR from "swr";
const fetcher = (url) => fetch(url).then((r) => r.json());
export default function LiveSearch({ query }) {
const { data, isLoading } = useSWR(`/api/search?q=${query}`, fetcher);
if (isLoading) return <p>Searching...</p>;
return <ResultsList results={data} />;
}
Choosing a Strategy
- Static generation — content is the same for every visitor and changes rarely (marketing pages, documentation).
- ISR — content changes occasionally and slightly stale data for a short window is acceptable (product catalogs, blog listings).
- Server-side rendering — content must be exactly current or is personalized per request (dashboards, account pages).
- Client-side fetching — content depends on client interaction that cannot be known at request time (live search, infinite scroll).
Best Practices
- Default to static generation or ISR whenever the data does not need to be perfectly real-time.
- Use
revalidatePath/revalidateTagafter mutations instead of relying purely on time-based revalidation for content that changes on user action. - Tag related
fetchcalls with the same cache tag so a single revalidation call can invalidate everything that depends on that data. - Reserve full server-side rendering for genuinely personalized or highly time-sensitive pages, since it cannot benefit from edge caching.
- Use a client-side data library for interactive, client-driven data needs rather than forcing everything through Server Components.
Common Mistakes to Avoid
- Marking every route as dynamic "just to be safe," losing the performance and cost benefits of caching for content that rarely changes.
- Forgetting to revalidate cached data after a mutation, leaving users looking at stale content.
- Fetching the same data separately in multiple components instead of relying on Next.js's request-level deduplication for identical
fetchcalls. - Using client-side fetching for the initial page load when a Server Component could have fetched and rendered that data directly, avoiding an extra round trip.
Fetching in Parallel vs Sequentially
How you structure await calls across Server Components has a direct impact on page load time, just as it does in any other asynchronous code. Fetching independent pieces of data sequentially wastes time waiting unnecessarily:
// Sequential: total time is roughly the sum of both requests
export default async function Dashboard({ userId }) {
const user = await getUser(userId);
const stats = await getStats(userId); // doesn't start until getUser finishes
return <DashboardView user={user} stats={stats} />;
}
Since user and stats do not depend on each other, starting both requests immediately and awaiting them together cuts the total wait roughly to the slower of the two, instead of the sum of both:
// Parallel: both requests start at the same time
export default async function Dashboard({ userId }) {
const [user, stats] = await Promise.all([
getUser(userId),
getStats(userId),
]);
return <DashboardView user={user} stats={stats} />;
}
This becomes even more important as a page's data requirements grow — a dashboard pulling from five independent data sources sequentially could take five times longer to render its first byte than the same page fetching all five in parallel. It is a small structural change with an outsized effect on real-world load times, and it is worth reviewing any Server Component with multiple await calls to confirm they actually need to run one after another.
Streaming with Suspense
Even a dynamically rendered page does not have to make the user wait for the slowest piece of data before showing anything. Wrapping a slow-loading section in React's Suspense boundary lets Next.js stream the fast parts of the page immediately and fill in the slow part once it resolves:
import { Suspense } from "react";
export default function Dashboard() {
return (
<div>
<Header /> {/* renders immediately */}
<Suspense fallback={<p>Loading recent activity...</p>}>
<RecentActivity /> {/* streams in once its data is ready */}
</Suspense>
</div>
);
}
Under the hood, the server sends the initial HTML shell — including the fallback — right away, then pushes the actual content for RecentActivity down the same connection once its data finishes loading, with React automatically swapping the fallback for the real content on the client without a full page reload. This turns "one slow query blocks the entire page" into "one slow section shows a lightweight loading state while everything else is already interactive," which is a meaningfully better experience for pages that combine fast, cheap data (like a user's name) with slower, more expensive data (like an aggregated report). It is worth noting that streaming requires a server that keeps the connection open, so this pattern is specific to dynamic or Node.js runtime rendering rather than fully static export.
Conclusion
Next.js does not force an all-or-nothing choice between static and dynamic rendering — it lets you choose per route, and even per fetch call, exactly how fresh the data needs to be. Matching the strategy to the actual freshness requirements of each page is what makes an app both fast and correct.
Article FAQ
References
Comments
Comments are coming soon. Meanwhile, share your feedback via our contact page.
Related Articles
View allNext.js Server Components Explained
Understand React Server Components in Next.js: what runs on the server versus the client, how they reduce bundle size, and how to combine them with Client Co...
Next.js App Router: A Beginner's Guide
A beginner's guide to the Next.js App Router covering file-based routing, layouts, loading and error states, and how it differs from the older Pages Router.
Caching Strategies for System Design
Explore common caching strategies used in system design, including cache-aside, write-through, write-back, and cache invalidation approaches, with trade-offs...
System Design Interview Preparation Guide
A structured approach to preparing for system design interviews, covering the framework interviewers expect, common topics to study, and how to communicate y...