Type something to search...
Next.js Fetching Data

Next.js Fetching Data

If you learned data fetching in the Pages Router, or in plain React, the App Router's approach can feel like it's asking you to unlearn a habit. There's no getServerSideProps, no getStaticProps, and no single lifecycle method that owns "when data loads." Instead, data fetching is just async JavaScript, spread across whichever component actually needs the data, and the framework decides how to render around it.

That flexibility is powerful, but it also means the responsibility for a fast, well-behaved page shifts onto you. Fetch in the wrong place and you create a waterfall that blocks the whole route. Fetch without thinking about caching and you hammer your database on every request. Fetch in a Client Component when a Server Component would have been simpler and you ship JavaScript the user never needed to download. This article walks through where data fetching happens in the App Router, how streaming keeps a slow query from freezing your entire page, and the patterns — sequential, parallel, and cached — that separate a snappy app from a sluggish one.

Two Places to Fetch, One Mental Model

The App Router splits your component tree into Server Components and Client Components. Server Components run only on the server and never ship their code to the browser; Client Components run in the browser (after an initial server render) and can use hooks, state, and browser APIs. That split determines how you fetch data:

  • In Server Components, you fetch with plain async/await — no special hook, no library required. You can call fetch, or reach for an ORM or database client directly, because none of that code or its credentials ever reaches the browser.
  • In Client Components, you either use React's use API to unwrap a promise handed down from a Server Component, or reach for a community library like SWR or TanStack Query when you need client-side refetching, caching, and revalidation.

The default, and the one you should reach for unless you have a specific reason not to, is fetching in Server Components. It keeps your bundle smaller, keeps your credentials off the client, and lets the framework stream results to the browser as they become ready. Client-side fetching earns its place when the data is genuinely interactive — a live search-as-you-type box, a poll that refreshes every few seconds, anything that needs to refetch in response to user behavior without a full navigation.

Fetching Data in Server Components

With the fetch API

The simplest case: make your component async, await the fetch, and use the result directly in JSX.

// app/blog/page.tsx
export default async function Page() {
  const data = await fetch("https://api.vercel.app/blog");
  const posts = await data.json();

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

There's no useEffect, no loading state to manage by hand, no dependency array to get wrong. The component simply doesn't render until the data it needs has resolved. That simplicity is the entire point of Server Components — you write the same straight-line code you'd write in a Node.js script, and Next.js takes care of turning it into HTML.

Two behaviors are worth internalizing here, because they trip up almost everyone coming from an older mental model of Next.js:

Identical fetch calls are automatically deduplicated within a single render. If your Page component and three of its children all call fetch('https://api.vercel.app/blog') during the same request, React memoizes that call and only actually hits the network once. This is a genuinely useful property: it means you don't need to fetch data at the top of your tree and drill it down through props just to avoid duplicate requests. Fetch it exactly where you use it, and let the framework de-duplicate for you.

fetch is not cached across requests by default. This is a meaningful change from how the App Router used to behave in earlier Next.js versions, where fetch calls were cached automatically unless you opted out. As of the version of Next.js this project runs (16.x), a fetch call blocks the page from rendering until the request completes, and it does that fresh on every request unless you explicitly opt into caching with the use cache directive, or you stream the component with <Suspense> so it doesn't block everything else. If you're following an old tutorial or blog post that assumes fetch is cached "for free," you'll get correct-but-slow behavior in a current Next.js project — not a bug, just an old assumption that no longer holds. Treat every fetch call as uncached until you've deliberately decided otherwise.

Also worth knowing: in development, you can turn on request logging to see exactly which fetch calls are firing and when, which is invaluable for spotting an accidental duplicate request or an unexpectedly slow endpoint. It's a config option in next.config.js under logging, and it will save you from adding your own console.log sprinkled through every data function.

With an ORM or Database Client

Because Server Components never ship to the browser, you can query a database directly with an ORM like Prisma or Drizzle, with no API route in between.

// app/blog/page.tsx
import { db, posts } from "@/lib/db";

export default async function Page() {
  const allPosts = await db.select().from(posts);

  return (
    <ul>
      {allPosts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

This is one of the App Router's genuinely nice properties for full-stack apps: your page component is your data-access layer. No /api/posts route to build, maintain, and version just to hand JSON to a page that lives right next to it.

The catch is that "no API route in between" also means no boundary reminding you to check permissions. A common mistake is treating a Server Component as inherently safe just because it doesn't ship to the client — but if you query the database directly inside a page that renders per-user data, you still need to check that the requesting user is allowed to see that data, the same as you would in an API handler. The component being server-only protects your credentials and query logic from being exposed in the bundle; it does not protect your data from being fetched for the wrong user. If you're building anything beyond a toy project, read through the App Router's data security guide before you wire up your first ORM call, and get in the habit of checking the session on every data-touching component, not just the ones that "feel" sensitive.

Streaming: Why One Slow Query Shouldn't Freeze the Whole Page

Here's the problem streaming solves: if you fetch data in a Server Component with a plain await, the entire route waits for that fetch to finish before Next.js sends any HTML to the browser. One slow analytics query in the sidebar, and your whole page — header, nav, and all — sits on a blank screen until it resolves.

Streaming breaks the page into chunks and sends each one to the browser as soon as it's ready, instead of holding everything hostage to the slowest piece. You get two ways to do this, and they solve different-sized problems.

loading.js: Stream the Whole Page

Drop a loading.js file next to a page.js and Next.js automatically wraps that page in a <Suspense> boundary using your loading file as the fallback.

// app/blog/loading.tsx
export default function Loading() {
  return <div>Loading...</div>;
}

On navigation, the user sees the surrounding layout plus this loading UI immediately, and the real content swaps in once the page finishes rendering on the server. It costs you nothing beyond creating the file — no manual <Suspense> wrapping required.

The gotcha, and it's a real one: loading.js only helps if the slow work lives inside page.js. If a layout above that page reads something like cookies(), headers(), or makes an uncached fetch, the layout itself blocks navigation and loading.js never gets a chance to kick in — because the framework can't show you the page's fallback until the layout around it has finished rendering. If you've added a loading.js file and you're still staring at a blank screen during navigation, this is almost always why: check whether the slow, uncached work actually lives in a layout, not the page. The fix is either to move that data access down into the page itself, or to wrap it in its own <Suspense> boundary with a dedicated fallback so it doesn't block the parent.

<Suspense>: Stream Just a Piece

For anything more granular than "the whole page," wrap the slow part directly in <Suspense>:

// app/blog/page.tsx
import { Suspense } from "react";
import BlogList from "@/components/BlogList";
import BlogListSkeleton from "@/components/BlogListSkeleton";

export default function BlogPage() {
  return (
    <div>
      {/* Sent to the client immediately */}
      <header>
        <h1>Welcome to the Blog</h1>
        <p>Read the latest posts below.</p>
      </header>
      <main>
        {/* Streamed in once BlogList resolves */}
        <Suspense fallback={<BlogListSkeleton />}>
          <BlogList />
        </Suspense>
      </main>
    </div>
  );
}

This is the pattern I reach for constantly: a page with a header and hero content that has nothing to do with a database, plus a list or dashboard widget further down that genuinely needs a slow query. The header ships instantly; the list streams in a beat later with a skeleton in its place. Users perceive this as fast even when the underlying query takes a second or two, because they're never staring at a blank white page.

A practical note the docs don't spell out clearly: <Suspense> boundaries you author yourself compose with the automatic one from loading.js. You can have a page-level loading.js for the initial navigation, and then a nested <Suspense> further down the tree around one specific slow widget, so that widget can re-suspend (say, after a client-side action revalidates it) without re-triggering the entire page's loading state. Think of loading.js as the coarse, free default, and manual <Suspense> as the scalpel you use once you know exactly which piece is slow.

Loading States People Actually Understand

It's tempting to slap a generic spinner on every fallback and move on, but a spinner tells the user nothing except "wait." A better fallback approximates the shape of the real content — a skeleton with the right number of list rows, a gray box the size of the eventual image, the title and cover photo of an article while its body streams in below. It costs a little more design effort up front, but it measurably reduces the feeling of a page being "broken" or "stuck," especially on slower connections. If you only take one thing from this section, make it this: build one real skeleton component per major data-dependent section of your UI, and reuse it, rather than writing <div>Loading...</div> everywhere and hoping nobody notices.

One more thing worth knowing if you ever wonder why a page seems to load instantly for a search engine crawler but shows visible loading states for a real visitor: bots and crawlers are served differently. Next.js waits for all data fetching to finish and sends the fully rendered HTML to known crawlers instead of streaming it progressively, so your SEO isn't punished by a page that streams in pieces for humans.

Fetching Data in Client Components

Sometimes the data really does belong on the client — a component that needs to refetch based on user interaction without a full page navigation, for instance. You have two options.

Streaming a Server-Fetched Promise with use()

Rather than fetching directly inside a Client Component, the recommended pattern is to start the fetch in a Server Component, pass the unresolved promise down as a prop, and unwrap it inside the Client Component with React's use() API.

// app/blog/page.tsx
import Posts from "@/app/ui/posts";
import { Suspense } from "react";

export default function Page() {
  // Don't await this — pass the promise straight through
  const posts = getPosts();

  return (
    <Suspense fallback={<div>Loading...</div>}>
      <Posts posts={posts} />
    </Suspense>
  );
}
// app/ui/posts.tsx
"use client";
import { use } from "react";

export default function Posts({
  posts,
}: {
  posts: Promise<{ id: string; title: string }[]>;
}) {
  const allPosts = use(posts);

  return (
    <ul>
      {allPosts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

Notice the parent deliberately does not await getPosts(). If it did, the whole point would be lost — the page would block on the server until the promise resolved, exactly like a plain server fetch. By handing the unresolved promise to the Client Component and letting the surrounding <Suspense> boundary handle the fallback, the server can start streaming everything around this component immediately, and use() on the client picks up the result the moment it's ready.

This is a subtler pattern than it first appears, so here's the rule of thumb: resolve a promise with await when the component that owns it is a Server Component and you want the framework to wait for it (optionally behind its own <Suspense>); resolve it with use() when you specifically want a Client Component further down the tree to suspend on it instead. If you need to share one promise across several sibling Client Components without prop-drilling it through every layer, put it in a context provider instead of passing it down as a prop to each one individually.

Reaching for SWR or TanStack Query

For anything that needs to refetch on an interval, revalidate on window focus, or manage optimistic updates from client-triggered mutations, a dedicated data-fetching library does a lot of work you'd otherwise hand-roll yourself.

// app/blog/page.tsx
"use client";
import useSWR from "swr";

const fetcher = (url: string) => fetch(url).then((r) => r.json());

export default function BlogPage() {
  const { data, error, isLoading } = useSWR(
    "https://api.vercel.app/blog",
    fetcher,
  );

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;

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

The honest reason to reach for SWR or TanStack Query instead of use() isn't caching in the abstract — the App Router already gives you server-side caching primitives. It's that these libraries manage a client-side cache with its own lifecycle: revalidate-on-focus, background refetch on an interval, deduplication of in-flight requests across components that mount and unmount independently of any single server render. If your component needs to keep polling for fresh data after the initial page load, without the user navigating anywhere, that's squarely client-library territory, not something use() alone gives you.

One practical trade-off worth naming: reaching for a client library means that component's data-fetching logic runs entirely in the browser, and its first fetch happens only after the JavaScript for that component has downloaded and hydrated — there's no server-rendered content to show in the meantime unless you seed it. If you want the best of both, fetch initial data on the server, pass it as the library's initial/fallback data, and let the client library take over from there for subsequent refetches.

Common Data-Fetching Patterns

Sequential Fetching (and the Waterfall It Creates)

Sequential fetching happens naturally whenever one request needs data from another before it can start:

// app/artist/[username]/page.tsx
export default async function Page({
  params,
}: {
  params: Promise<{ username: string }>;
}) {
  const { username } = await params;
  const artist = await getArtist(username);

  return (
    <>
      <h1>{artist.name}</h1>
      <Suspense fallback={<div>Loading...</div>}>
        <Playlists artistID={artist.id} />
      </Suspense>
    </>
  );
}

async function Playlists({ artistID }: { artistID: string }) {
  const playlists = await getArtistPlaylists(artistID);

  return (
    <ul>
      {playlists.map((playlist) => (
        <li key={playlist.id}>{playlist.name}</li>
      ))}
    </ul>
  );
}

Playlists genuinely can't start until getArtist resolves, because it needs the artist's ID — this is real, unavoidable sequencing, not a mistake. Wrapping it in <Suspense> at least lets the artist's name render before the playlists have loaded, rather than blocking on both together.

What is worth catching yourself on: accidental sequential fetching, where two requests don't actually depend on each other but end up chained anyway just because of how the code reads top-to-bottom:

// Accidentally sequential — albums waits on artist for no reason
const artist = await getArtist(username);
const albums = await getAlbums(username);

If getAlbums doesn't need anything from artist, this costs you a full round-trip of latency for nothing. It's an easy trap to fall into because the code looks perfectly reasonable — nothing here screams "bug." The tell is asking yourself, for every await in a row, whether the second call actually reads a value produced by the first. If it doesn't, it shouldn't be sequential.

Also worth remembering: a genuinely sequential chain like the artist/playlists example above still blocks on its first request no matter how you structure the rest of the page — if getArtist is slow, consider wrapping the entire page in its own <Suspense> (via loading.js) so users see something immediately instead of a blank screen while even the artist name loads.

Parallel Fetching with Promise.all

When requests don't depend on each other, start them all before awaiting any of them:

// app/artist/[username]/page.tsx
import Albums from "./albums";

async function getArtist(username: string) {
  const res = await fetch(`https://api.example.com/artist/${username}`);
  return res.json();
}

async function getAlbums(username: string) {
  const res = await fetch(`https://api.example.com/artist/${username}/albums`);
  return res.json();
}

export default async function Page({
  params,
}: {
  params: Promise<{ username: string }>;
}) {
  const { username } = await params;

  // Both requests start here, immediately
  const artistData = getArtist(username);
  const albumsData = getAlbums(username);

  const [artist, albums] = await Promise.all([artistData, albumsData]);

  return (
    <>
      <h1>{artist.name}</h1>
      <Albums list={albums} />
    </>
  );
}

The trick is subtle but important: calling getArtist(username) and getAlbums(username) without await in front of them kicks off both network requests immediately — you're capturing the promises, not the resolved values. Only when you hand both promises to Promise.all do you actually wait, and by then they've both been in flight the whole time. Total wait time becomes roughly the slower of the two requests, not the sum of both.

The one behavior worth knowing before you rely on this pattern in production: Promise.all fails all-or-nothing. If either request rejects, the whole Promise.all rejects, even if the other one would have succeeded fine. If you'd rather show partial data — the artist's name even if their albums API happens to be down — swap in Promise.allSettled and check each result's status individually instead of destructuring blindly.

Reusing Data with React.cache

fetch calls get automatic memoization within a single render, as covered earlier. But plenty of real data access doesn't go through fetch at all — an ORM call, a third-party SDK, a computation that hits a cache layer — and none of that gets deduplicated automatically. React.cache closes that gap for arbitrary async functions:

// app/lib/user.ts
import { cache } from "react";

export const getUser = cache(async () => {
  const res = await fetch("https://api.example.com/user");
  return res.json();
});
// app/dashboard/page.tsx
import { getUser } from "../lib/user";

export default async function DashboardPage() {
  const user = await getUser(); // First call — actually fetches
  return <h1>Dashboard for {user.name}</h1>;
}

If three separate components in the same request tree all call getUser(), only the first call actually executes the function body; the rest receive the same memoized result. This is the same pattern that lets you avoid prop-drilling a user object through five layers of components just so the sixth one can use it — instead, every component that needs the current user simply calls getUser() directly, and React.cache makes sure that's cheap.

The scope here matters and is easy to get wrong: React.cache's memoization lives for exactly one request. It doesn't persist between requests, doesn't share state across different users, and doesn't replace use cache or a real caching layer if what you actually want is to avoid hitting your database on every request, not just avoid hitting it twice in the same request. Treat React.cache purely as request-scoped deduplication, and reach for the caching directive (or a cache layer in front of your database) when you want data to persist across requests.

Mistakes I See Constantly

A handful of things trip people up often enough that they're worth calling out directly, since none of them are obvious from reading the docs top to bottom.

Forgetting that params is a promise. Every example above awaits params before destructuring it — const { username } = await params. If you copy an older code sample where params is used directly as an object, you'll get a type error or a runtime warning depending on your Next.js version. Always await it.

Assuming fetch is cached because it "used to be." Covered above, but it bears repeating because it's the single most common source of "why is my page suddenly slow" questions from people who've used an older Next.js version or followed an outdated tutorial. Uncached is the default now; caching is something you opt into.

Fetching client-side by default out of habit. If you're coming from a plain React or Create React App background, useEffect + fetch is muscle memory. In the App Router, that pattern ships more JavaScript, delays the first fetch until after hydration, and loses you the automatic request memoization Server Components get for free. Default to fetching on the server; only move to the client when there's a genuine interactivity requirement.

Not handling fetch errors. Unlike some HTTP client libraries, fetch does not throw on a non-2xx response — a 404 or a 500 resolves just fine and hands you a response object with ok: false. If you don't check response.ok or the status code yourself, a failed request can silently render an empty list or throw a confusing downstream error about undefined instead of the actual problem.

Blocking an entire page on one optional widget. If a sidebar analytics chart is slow and not essential to the page's core content, it shouldn't be part of the same blocking await chain as the main content. Give it its own <Suspense> boundary so a slow third-party API doesn't take your whole page down with it.

Key Takeaways

ScenarioApproach
Fetching data that renders on first loadasync/await directly in a Server Component
Querying a database or ORMCall it directly inside a Server Component — no API route needed
One slow section shouldn't block the pageWrap it in <Suspense> with a meaningful fallback
Whole route should show a fallback on navigationAdd a loading.js file next to the page
Client Component needs server-fetched dataPass the unresolved promise down, unwrap with use()
Client Component needs polling/refetchingUse SWR or TanStack Query
Two fetches don't depend on each otherStart both, then Promise.all (or allSettled)
Same non-fetch data needed in multiple componentsWrap the function in React.cache
Data should persist across requests, not just within oneUse the use cache directive or a real cache layer, not React.cache

Data fetching in the App Router isn't really a single feature — it's a handful of small, composable primitives (async components, <Suspense>, use(), React.cache) that you combine differently depending on where the data lives and how urgently the user needs to see it. Once the mental model clicks — fetch where you use it, stream what's slow, cache what repeats — most of the API stops feeling like new syntax to memorize and starts feeling like the same handful of decisions applied consistently across every page you build.

Tags :
Share :

Related Posts

Can Next.js Be Used with GraphQL?

Can Next.js Be Used with GraphQL?

Next.js and GraphQL are two powerful technologies that have gained significant traction in the web development community. Next.js, a React-based fram

Dive Deeper
How does Next.js differ from Create React App?

How does Next.js differ from Create React App?

In the world of modern web development, React.js has emerged as a dominant force due to its flexibility, performance, and extensive ecosystem. Two po

Dive Deeper
How does Next.js handle image optimization?

How does Next.js handle image optimization?

In modern web development, image optimization plays a critical role in enhancing user experience and improving site performance. Large, unoptimized i

Dive Deeper