Skip to content
lazy devs
4 min readLazy Devs

Data fetching patterns in React Server Components

How to fetch data in React Server Components without waterfalls, prop-drilling, or a client cache library. Real Next.js patterns and trade-offs.

React Server Components changed where your data fetching lives, and most of the confusion comes from carrying old habits across that line. You no longer need a client cache library to render a list, and you no longer need a loading spinner for every async boundary. But you do need to think about waterfalls, request deduplication, and where the await actually happens. Here is how we structure data fetching in real Next.js App Router projects.

Fetch in the component that needs the data

The single biggest mental shift: a Server Component can be async, and it can fetch its own data. You do not lift state, you do not pass a userId down four levels so a leaf can call an API. The component that renders the data is the component that fetches it.

// app/projects/[id]/page.tsx
import { db } from "@/lib/db";
import { notFound } from "next/navigation";
 
export default async function ProjectPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const project = await db.project.findUnique({ where: { id } });
 
  if (!project) notFound();
 
  return (
    <main>
      <h1>{project.name}</h1>
      <ProjectStats projectId={project.id} />
    </main>
  );
}

Two things worth noticing. First, you can call your database directly. There is no fetch to your own API route, no serialization round trip, no extra network hop. The query runs on the server during render. Second, ProjectStats fetches its own data too, which is where the next problem starts.

Avoid the accidental waterfall

The trap with "fetch where you render" is that nested awaits run in sequence. If ProjectPage awaits the project, then ProjectStats awaits stats, then a child of stats awaits something else, you have built a waterfall: each request waits for the one above it to finish before it even starts.

When two pieces of data do not depend on each other, start both requests before you await either one.

export default async function ProjectPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
 
  // Both queries start immediately, then we await in parallel.
  const projectPromise = db.project.findUnique({ where: { id } });
  const activityPromise = db.activity.findMany({
    where: { projectId: id },
    take: 20,
  });
 
  const [project, activity] = await Promise.all([
    projectPromise,
    activityPromise,
  ]);
 
  if (!project) notFound();
 
  return <ProjectView project={project} activity={activity} />;
}

The rule of thumb: independent data fetches belong in a Promise.all. Dependent fetches (you need the project before you can query its owner) stay sequential, because they genuinely have to be. Do not parallelize things that depend on each other just to look fast. You will fetch with stale or missing inputs.

Let Suspense break the blocking

Sometimes one query is slow and the rest of the page is ready. You do not want the whole route to block on the slow one. Move the slow fetch into its own component and wrap it in Suspense. The fast content renders immediately, and the slow part streams in when it resolves.

import { Suspense } from "react";
 
export default async function ProjectPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const project = await db.project.findUnique({ where: { id } });
  if (!project) notFound();
 
  return (
    <main>
      <h1>{project.name}</h1>
      <Suspense fallback={<StatsSkeleton />}>
        <ProjectStats projectId={project.id} />
      </Suspense>
    </main>
  );
}
 
async function ProjectStats({ projectId }: { projectId: string }) {
  // This can take a second. The header already rendered.
  const stats = await db.stats.computeFor(projectId);
  return <StatsPanel stats={stats} />;
}

Here the waterfall is intentional and fine. You needed the project to exist before showing its shell, but the expensive stats query is decoupled by the Suspense boundary. The browser gets the page heading right away and fills in the stats panel afterward. This is the pattern we reach for most: cheap data inline, expensive data behind Suspense.

Deduplicate shared fetches with React cache

A real app fetches the current user in the layout, again in the page, and maybe again in a nav component. You do not want three identical database queries per request. React gives you cache for exactly this. It memoizes the function for the duration of a single server render, so repeated calls with the same arguments hit the database once.

// lib/data/user.ts
import { cache } from "react";
import { db } from "@/lib/db";
 
export const getCurrentUser = cache(async (sessionId: string) => {
  return db.user.findFirst({
    where: { sessions: { some: { id: sessionId } } },
  });
});

Call getCurrentUser(sessionId) in the layout, the page, and a sidebar, and only the first call actually queries. The rest return the same in-flight promise. Note that this dedupes per request, not across requests. For caching across requests you want Next.js caching primitives (fetch cache options, or the use cache directive on newer versions), which is a separate concern with its own invalidation story.

If you fetch over HTTP with the native fetch, Next.js already dedupes identical calls within a render, so you get this behavior for free. The cache wrapper is what you need for direct database calls and other non-fetch work.

Keep the client boundary thin

Server Components cannot use state or effects, so anything interactive needs a Client Component. The pattern that keeps things clean: fetch on the server, pass plain serializable data down as props, and let the Client Component own only the interactivity.

// Server Component fetches, Client Component handles interaction.
export default async function CommentsSection({ postId }: { postId: string }) {
  const comments = await db.comment.findMany({ where: { postId } });
  return <CommentList initialComments={comments} postId={postId} />;
}

CommentList is "use client", takes the already-fetched comments, and handles optimistic updates or filtering on top of them. You are not shipping a fetch waterfall to the browser, and you are not handing the client a database client it has no business touching. Remember that everything crossing this boundary must serialize, so pass dates and IDs, not Prisma model instances with methods or circular references.

Mutations belong in Server Actions

Fetching is only half the story. For writes, Server Actions let a form or button call server code directly, then revalidate the affected data so the next render is fresh.

// app/projects/actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { db } from "@/lib/db";
 
export async function renameProject(id: string, name: string) {
  await db.project.update({ where: { id }, data: { name } });
  revalidatePath(`/projects/${id}`);
}

After the update, revalidatePath tells Next.js to refetch the Server Components for that route. No manual cache invalidation, no client refetch hook. The data fetching pattern and the mutation pattern stay symmetric, which is most of why this model is pleasant to work with once it clicks.

Takeaway

Fetch data in the component that renders it, parallelize anything independent with Promise.all, push slow queries behind Suspense so they stream, and wrap shared reads in React cache to dedupe. Keep Client Components at the leaves and hand them plain data. Do that and most of the "where does my data fetching go" anxiety in React Server Components disappears.

If your team is mid-migration to the App Router and the waterfalls are biting, we are happy to take a look.

Related service

React Development Services

Maintainable React interfaces and applications.

Learn more

Want this built right?

This is the work we do every day. Tell us what you are building and we will show you exactly how we would ship it.

hello@lazydevsagency.com