Skip to content
C Codeloom
Next.js

Data Fetching in Next.js: fetch, cache, and revalidate

A practical guide to data fetching in Next.js — async Server Components, the fetch cache, no-store and revalidate options, generateStaticParams for static routes, and server actions for mutations.

·11 min read · By Yash Kesharwani
Intermediate 13 min read

What you'll learn

  • How to fetch data inside async Server Components
  • The default caching behavior of fetch() in Next.js 15
  • When to use cache: no-store and next: { revalidate: N }
  • How to pre-render dynamic routes with generateStaticParams
  • How server actions handle mutations and revalidate the cache
  • When to reach for a client-side data library instead

Prerequisites

Data fetching in Next.js looks different from anything you have seen in a plain React app. There is almost no useEffect. Most of it is await fetch(...) inside an async component, with a small set of options that decide how it caches. This post walks through the model end to end.

Fetch inside Server Components

The default pattern is the simplest one in modern React:

// app/posts/page.tsx
export default async function PostsPage() {
  const res = await fetch('https://api.example.com/posts');
  const posts = await res.json();

  return (
    <ul>
      {posts.map((p: { id: string; title: string }) => (
        <li key={p.id}>{p.title}</li>
      ))}
    </ul>
  );
}

That fetch runs on the server during render. The browser never sees the URL, the headers, or the response — just the resulting HTML. We covered the why and the boundary rules in Next.js Server Components Explained.

You can also talk to a database directly with no fetch involved:

import { db } from '@/lib/db';

export default async function PostsPage() {
  const posts = await db.post.findMany({ orderBy: { createdAt: 'desc' } });
  return (
    <ul>
      {posts.map((p) => (
        <li key={p.id}>{p.title}</li>
      ))}
    </ul>
  );
}

Same model, fewer hops.

The fetch cache

Next.js extends the standard fetch API with caching options. In Next.js 15 the default for fetch is uncached — every call hits the network, every render. This is a change from earlier versions, where fetches were cached by default. The new default is more predictable.

You opt into caching explicitly:

// Cache the response forever (until the deploy or a revalidate)
const res = await fetch('https://api.example.com/posts', {
  cache: 'force-cache',
});

// Cache the response for 60 seconds, then revalidate in the background
const res = await fetch('https://api.example.com/posts', {
  next: { revalidate: 60 },
});

// Bypass the cache entirely
const res = await fetch('https://api.example.com/posts', {
  cache: 'no-store',
});

A short tour of each option.

cache: 'force-cache'

Cache the response indefinitely. Use this for genuinely immutable data — content from a CMS that you control with explicit revalidation, currency reference data, anything keyed by version.

next: { revalidate: N }

Incremental Static Regeneration. The response is cached for N seconds. The first request after expiry returns the cached value and triggers a background revalidation. The next request gets the fresh value. Visitors never wait for a regeneration.

This is the workhorse for content that changes occasionally — blog posts, product catalogs, public dashboards.

const posts = await fetch('https://cms.example.com/posts', {
  next: { revalidate: 300 }, // 5 minutes
}).then((r) => r.json());

cache: 'no-store'

Bypass the cache. Every request hits the upstream. Use for personalized data, anything tied to the current user, or anything that must be fresh.

const me = await fetch('https://api.example.com/me', {
  headers: { Cookie: cookies().toString() },
  cache: 'no-store',
}).then((r) => r.json());

Reading cookies() or headers() from next/headers in a Server Component automatically makes that part of the response dynamic — Next.js will not statically pre-render it.

Tag-based revalidation

For finer control, tag your fetches and invalidate by tag:

// Fetch and tag
const posts = await fetch('https://api.example.com/posts', {
  next: { tags: ['posts'] },
}).then((r) => r.json());

Anywhere on the server, call revalidateTag to mark every cached response with that tag stale:

'use server';

import { revalidateTag } from 'next/cache';

export async function publishPost() {
  // ... write to db ...
  revalidateTag('posts');
}

The next request for any URL whose render touches a posts-tagged fetch will re-run that fetch. This is how you keep static pages fresh without polling.

Static vs dynamic rendering

A page in the App Router is statically rendered unless something forces it to be dynamic. Static pages are rendered once at build time and cached as HTML. Dynamic pages render on every request.

The triggers for dynamic rendering:

  • A fetch with cache: 'no-store'
  • A call to cookies(), headers(), draftMode() from next/headers
  • Reading searchParams on a page
  • export const dynamic = 'force-dynamic' at the top of the file

If none of those are present, the page is static.

You can force the mode explicitly:

// Force static even if a dynamic API is used (you take responsibility)
export const dynamic = 'force-static';

// Force dynamic, even if nothing else triggers it
export const dynamic = 'force-dynamic';

The next build output prints which mode each route ended up in. Make a habit of reading it.

Try it yourself. Build a page that fetches from a public API with no cache options. Run npm run build and observe whether the route renders statically. Now add cache: 'no-store' and rebuild. The route should flip from static to dynamic. Now remove no-store and add next: { revalidate: 30 }. The route should be statically generated but revalidated every 30s — Next prints this as “ISR” in the route table.

generateStaticParams

For dynamic routes like app/blog/[slug]/page.tsx, Next.js needs to know which slugs to pre-render at build time. generateStaticParams is how you tell it.

// app/blog/[slug]/page.tsx
type Params = { slug: string };

export async function generateStaticParams(): Promise<Params[]> {
  const posts = await fetch('https://cms.example.com/posts').then((r) =>
    r.json()
  );

  return posts.map((p: { slug: string }) => ({ slug: p.slug }));
}

export default async function PostPage({
  params,
}: {
  params: Promise<Params>;
}) {
  const { slug } = await params;
  const post = await fetch(`https://cms.example.com/posts/${slug}`, {
    next: { revalidate: 300 },
  }).then((r) => r.json());

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.body}</p>
    </article>
  );
}

At build time, Next.js calls generateStaticParams, then renders the page once per returned param set. Visitors to those URLs get static HTML.

A few useful behaviors:

  • If a request comes in for a slug not returned by generateStaticParams, Next.js renders it on demand and caches the result (the default).
  • Set export const dynamicParams = false; to return a 404 for unknown slugs instead.
  • Combine with revalidate to keep the static pages fresh.

This is how a content-heavy site can ship hundreds of pre-rendered pages and still respond instantly when new content is added.

Parallel fetches

When a single component needs multiple unrelated pieces of data, kick them off together:

export default async function DashboardPage() {
  const [user, stats] = await Promise.all([
    fetch('https://api.example.com/me').then((r) => r.json()),
    fetch('https://api.example.com/stats').then((r) => r.json()),
  ]);

  return (
    <div>
      <h1>Hello, {user.name}</h1>
      <p>You have {stats.unread} unread messages.</p>
    </div>
  );
}

Each fetch runs in parallel. The page waits for the slower one, not the sum. Compare to awaiting them one after the other, which serializes the requests.

Deduplication

Next.js deduplicates identical fetch calls during a single render. If two components on the same page both call fetch('/api/me'), the request runs once. This means you can fetch the same data in multiple components without worrying about waterfalls — write what is clear, let the framework dedupe.

Server actions for mutations

We introduced server actions in the previous post. Here they are in the data-fetching picture.

A server action is a server-only function used to mutate data and refresh the cache:

// app/posts/actions.ts
'use server';

import { revalidatePath, revalidateTag } from 'next/cache';
import { redirect } from 'next/navigation';
import { db } from '@/lib/db';

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string;
  if (!title) throw new Error('Title is required');

  const post = await db.post.create({ data: { title } });

  // Mark the index page as stale so the new post appears next visit
  revalidatePath('/posts');
  // Or, if you tagged the fetches:
  revalidateTag('posts');

  redirect(`/posts/${post.id}`);
}

Wire the action straight to a form:

// app/posts/new/page.tsx
import { createPost } from '../actions';

export default function NewPostPage() {
  return (
    <form action={createPost}>
      <input name="title" required />
      <button type="submit">Create</button>
    </form>
  );
}

You can also call a server action from a Client Component event handler:

'use client';

import { createPost } from './actions';

export default function QuickAdd() {
  return (
    <button
      onClick={async () => {
        const fd = new FormData();
        fd.set('title', `Untitled ${Date.now()}`);
        await createPost(fd);
      }}
    >
      Quick add
    </button>
  );
}

The function runs on the server. The client gets back a typed result and any cache invalidations applied.

Route handlers

For genuine HTTP endpoints — webhooks, public APIs, OAuth callbacks — use a route handler at app/**/route.ts:

// app/api/posts/route.ts
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';

export async function GET() {
  const posts = await db.post.findMany();
  return NextResponse.json(posts);
}

export async function POST(req: Request) {
  const body = await req.json();
  const post = await db.post.create({ data: body });
  return NextResponse.json(post, { status: 201 });
}

Route handlers are the equivalent of small Express routes. They are the right tool for external clients, but for internal mutations from your own UI, server actions are simpler.

Try it yourself. Build a tiny blog. Page is a Server Component that lists posts from an in-memory array, tagged 'posts'. New post form uses a server action that pushes to the array and calls revalidateTag('posts'). Submit, navigate back to the index, confirm the new post appears without any client-side state. Now add next: { revalidate: 60 } to the index fetch and remove the tag — confirm new posts only appear after 60 seconds. You just used both invalidation strategies.

When to reach for a client library

The server-first model handles most cases, but a few situations still want client-side fetching:

  • Polling or live updates — websockets and Server-Sent Events are simpler client side
  • Optimistic UI with complex state — TanStack Query or SWR shine here
  • Large client-only apps with deep interactive trees that just happen to live inside Next

For those, drop into a Client Component and use the same fetch patterns we covered earlier. Server Components do not replace client fetching; they remove the need for it in the common case.

Common mistakes

A few patterns that show up in early code.

Calling a route handler from a Server Component

// Don't do this
const data = await fetch('http://localhost:3000/api/posts').then((r) =>
  r.json()
);

If both the page and the route handler are in the same project, the route handler is just a wrapper around the same data layer. Call the data layer directly:

import { db } from '@/lib/db';
const data = await db.post.findMany();

One fewer hop, one fewer place for things to fail.

Forgetting revalidatePath after a mutation

A server action that updates the database but never calls revalidatePath or revalidateTag will leave the user staring at stale cached HTML until the next deploy or the next manual reload.

Mixing dynamic APIs into a page meant to be static

A single cookies() call deep in the tree makes the whole route dynamic. If you needed it static, isolate the personalized part behind a <Suspense> boundary and let Next stream it in.

Recap

You now know:

  • Server Components fetch data with await fetch() — no useEffect required
  • The default in Next.js 15 is uncached; opt in with force-cache or next: { revalidate }
  • cache: 'no-store' and cookies()/headers() make a route dynamic
  • generateStaticParams pre-renders dynamic routes at build time
  • revalidatePath and revalidateTag invalidate the cache from server actions
  • Parallel fetches with Promise.all avoid waterfalls; Next deduplicates identical fetches
  • Server actions are the simplest path for mutations; route handlers are the right tool for external HTTP

Next steps

You now have the foundation to build real Next.js apps — routing, layouts, Server Components, fetching, mutations, and caching. The natural next step is to ship something. Pick a small project — a personal dashboard, a link tracker, a CRUD app for a hobby — and build it end to end with the patterns from this series.

→ Continue: Authentication in Next.js with Auth.js

Questions or feedback? Email codeloomdevv@gmail.com.