Next.js Data Fetching, Caching and Rendering Modes
Web DevelopmentLesson 3 of 46 min read
What you'll learn
- Fetch data in an async server component, in parallel and with streaming
- Tell static rendering from dynamic rendering and know what makes a route dynamic
- Choose between time-based and on-demand revalidation
- Handle version differences in default caching by setting options explicitly
On this page
A city bike scheme has a page showing how many bikes each docking station has free. If you render it once at build time, it will be wrong ten minutes later. If you render it from scratch for every visitor, your server and the data provider carry the load. The right answer is somewhere between: show a recent snapshot quickly and refresh it on a schedule. Next.js lets you choose where on that spectrum each page sits, and the choice comes down to a handful of options that are easy to mix up.
Fetching where the data is needed
In a Server Component you simply await your data. There is no useEffect and no client-side loading state for the first paint. The fetch happens on the server, the HTML arrives complete, and the API key (if there is one) never leaves your server.
type Station = { id: string; name: string; bikes: number; docks: number };
async function getStations(): Promise<Station[]> {
const res = await fetch("https://api.example.test/v1/stations", {
next: { revalidate: 30 }, // reuse the result for up to 30 seconds
});
if (!res.ok) {
throw new Error("Station feed responded with status " + res.status);
}
return res.json();
}
export default async function StationsPage() {
const stations = await getStations();
return (
<main>
<h1>Free bikes right now</h1>
<ul>
{stations.map((s) => (
<li key={s.id}>
{s.name}: {s.bikes} bikes, {s.docks} free docks
</li>
))}
</ul>
</main>
);
}With the feed returning data, the page lists one line per station such as "Canal Wharf: 7 bikes, 3 free docks". The revalidate: 30 option says the server may reuse a stored copy of this response for 30 seconds; after that, the next visitor still gets the stored copy while a fresh one is fetched in the background (stale-while-revalidate). The thrown error is caught by the nearest error.tsx from the previous lesson, so a broken feed shows your error UI rather than an empty list.
Static versus dynamic rendering
A route is rendered in one of two modes. Static routes are rendered once, at build time or during revalidation, and the resulting HTML is served from storage or a CDN. Dynamic routes are rendered on the server when each request arrives. Next.js picks automatically: a route is static unless something forces it to depend on the request.
| Situation | Resulting mode | Notes |
|---|---|---|
| Page with no request-specific input, data fetched with caching or none | Static | Fast, cheap, stale until revalidated |
Reads cookies(), headers() or the searchParams prop | Dynamic | Output depends on who asks |
fetch with cache: "no-store" | Dynamic | Opts the data out of caching |
export const dynamic = "force-dynamic" | Dynamic | Explicit override; not available in every newer caching model |
export const revalidate = 60 | Static, refreshed | Time-based regeneration of the whole route |
Dynamic route with generateStaticParams | Static for each listed value | Others may render on demand, depending on settings |
A search page for stations is inherently dynamic, because the result depends on the query string. In Next.js 15 and later, searchParams is a Promise just like params.
type Props = { searchParams: Promise<{ q?: string }> };
type Station = { id: string; name: string; bikes: number };
async function searchStations(q: string): Promise<Station[]> {
const url = "https://api.example.test/v1/stations?q=" + encodeURIComponent(q);
const res = await fetch(url, { cache: "no-store" }); // always fresh for this request
if (!res.ok) throw new Error("Search failed with status " + res.status);
return res.json();
}
export default async function SearchPage({ searchParams }: Props) {
const { q = "" } = await searchParams;
const results = q ? await searchStations(q) : [];
return (
<main>
<h1>Find a station</h1>
<form action="/stations/search">
<label>
Name <input name="q" defaultValue={q} />
</label>
<button type="submit">Search</button>
</form>
{q && (
<p>
{results.length} result(s) for <strong>{q}</strong>
</p>
)}
</main>
);
}Submitting the form navigates to /stations/search?q=wharf, the server runs the search, and the page shows a count under the form. Because the route reads searchParams, it is rendered per request. Nothing about this page can be pre-built, and that is correct.
Parallel fetching and streaming
Awaiting two independent requests one after the other doubles the wait. Start both and await them together. For slow sections, wrap them in Suspense so the rest of the page can reach the browser first; the slow part streams in when it is ready, with your fallback shown meanwhile.
import { Suspense } from "react";
async function Forecast() {
const res = await fetch("https://api.example.test/v1/forecast", { next: { revalidate: 600 } });
const data: { summary: string } = await res.json();
return <p>Weather: {data.summary}</p>;
}
async function Alerts() {
const res = await fetch("https://api.example.test/v1/alerts", { cache: "no-store" });
const items: { id: string; text: string }[] = await res.json();
return (
<ul>
{items.map((a) => (
<li key={a.id}>{a.text}</li>
))}
</ul>
);
}
export default function DashboardPage() {
return (
<main>
<h1>Operations</h1>
<Suspense fallback={<p>Loading forecast...</p>}>
<Forecast />
</Suspense>
<Suspense fallback={<p>Loading alerts...</p>}>
<Alerts />
</Suspense>
</main>
);
}The heading arrives immediately. The two sections fetch independently, each replacing its own fallback when ready, so a slow alerts service does not hold back the forecast. If you have only one dynamic section, this is also how you keep the rest of the page fast. For plain Promise.all, put both fetches in one component and await the pair.
Refreshing on demand
Time-based revalidation is easy but approximate. When something specific changes, such as a staff member updating a station, call a revalidation function. A route handler protected by a secret is a common trigger, and it can be called by your admin tool or a webhook.
import { revalidatePath } from "next/cache";
import { NextResponse } from "next/server";
export async function POST(request: Request) {
const expected = process.env.REVALIDATE_TOKEN;
const received = request.headers.get("x-revalidate-token");
if (!expected || received !== expected) {
return NextResponse.json({ ok: false }, { status: 401 });
}
revalidatePath("/stations");
return NextResponse.json({ ok: true });
}Route (app)
┌ ○ /
├ ○ /stations
├ ƒ /stations/search
└ ƒ /dashboard
○ (Static) prerendered as static content
ƒ (Dynamic) server-rendered on demandCommon mistakes
- Assuming dev mode behaves like production.
next devrenders on demand and skips most caching, so a page that looks live in dev may be frozen in a production build. Always test withnext buildfollowed bynext start. - Fetching your own API route from a Server Component. At build time there is no server listening, so the build fails with a fetch error such as
ECONNREFUSED. Call the shared function that the route handler uses instead. - Reading `cookies()` in a layout. One cookie read makes every route under that layout dynamic, and you lose static rendering without any warning in the code.
- Not awaiting `searchParams` or `params` in newer versions. You get a warning that they should be awaited, and in later releases an error.
- Relying on one default for cache behaviour. Upgrading from 14 to 15 changed it, so an unchanged app can suddenly refetch on every request or serve stale data.
- Forgetting error handling in `fetch`.
fetchonly rejects on network failure; a 500 response resolves normally, so checkres.okas above.
How this shows up in real projects and interviews
Most performance and "why is my data stale?" tickets in a Next.js codebase trace back to rendering mode and caching. Interviewers ask when you would use static generation, incremental regeneration or dynamic rendering, and the practical answer ties the choice to the data: marketing pages static, product catalogues revalidated, dashboards dynamic. You will also meet database access directly from server components, which pairs with REST API development when the data comes from a separate service.
Try it yourself
Build a page that lists the five most recent entries from any public JSON API you like. First render it statically and confirm in the next build output that the route is marked static. Then add revalidate: 60, then switch to cache: "no-store", and finally read a cookie. After each change, re-run next build and record how the route's symbol changes.
Show a hint
Make sure the API is reachable during build. Use next/headers for the cookie read: import { cookies } from "next/headers" and await cookies() in version 15 and later.
Key takeaways
- Server Components can
awaitdata directly; run independent fetches in parallel and useSuspenseto stream slow sections. - A route is static unless it reads request-specific input (
cookies(),headers(),searchParams) or opts out of caching. - Use
revalidatefor time-based refresh andrevalidatePathor tags for on-demand refresh, protecting the trigger with a secret. - Caching defaults changed between versions, so set options explicitly and verify with
next buildandnext start.
Test yourself
A trick from this lesson's ride: If a page must know who is asking, it cannot be baked ahead of time. Four more tricks like it wait along the route, each followed by a question.
Frequently asked questions
What is the difference between static and dynamic rendering in Next.js?
Static rendering produces the HTML once, at build time or when revalidated, and serves it from storage or a CDN. Dynamic rendering produces the HTML on the server for each request. Next.js chooses automatically, and a route becomes dynamic when it uses request-specific data such as cookies, headers or search parameters.
Does Next.js cache fetch requests by default?
It depends on the version. In Next.js 14, fetch results were cached by default. In recent versions, including 15, they are not cached by default, and you opt in with options like next: { revalidate } or cache: "force-cache". Newer releases are introducing a use cache model, so check the docs for your version.
How do I refresh cached data in Next.js without redeploying?
Use time-based revalidation with the revalidate option, or on-demand revalidation by calling revalidatePath or tag-based revalidation from a route handler or server action. Protect any public trigger with a secret token so strangers cannot flush your cache.