Data Fetching in the App Router
- nextjs
- react
- typescript
- webdev
- serverside
- appdev
- frontend
In the last article we drew the line between Server Components and Client Components: what runs where, and why that boundary matters for bundle size and interactivity. Data fetching is where that boundary actually pays off. Once you know a component runs on the server, fetching data inside it stops being a special case with hooks and loading flags, and becomes ordinary async code.
This is part of the React & Next.js Complete Guide series, and it's the article where the App Router's design starts to feel less like a new mental model and more like a simplification. A lot of the ceremony we built up around useEffect, useState, and client-side fetching libraries exists to work around a constraint that Server Components simply don't have: the need to fetch data from the browser, after the component has already rendered.
That said, "just fetch data in the component" is not the whole story. There are real decisions to make about parallelism, deduplication, and when a Server Component is the wrong tool at all. This article covers the patterns that hold up in production, not just the demo version.
Server Components fetch data directly, no hooks required
A Server Component is an async function. That's the entire trick. You can await a database call, an ORM query, or a fetch request directly in the component body, and Next.js renders the resulting HTML on the server before it ever reaches the client.
// app/dashboard/page.tsx
import { db } from "@/lib/db";
type Invoice = {
id: string;
amount: number;
status: "paid" | "pending" | "overdue";
};
async function getInvoices(organizationId: string): Promise<Invoice[]> {
return db.invoice.findMany({
where: { organizationId },
orderBy: { createdAt: "desc" },
take: 20,
});
}
export default async function DashboardPage({
searchParams,
}: {
searchParams: Promise<{ org: string }>;
}) {
const { org } = await searchParams;
const invoices = await getInvoices(org);
return (
<section>
<h1>Recent invoices</h1>
<ul>
{invoices.map((invoice) => (
<li key={invoice.id}>
{invoice.id} — ${invoice.amount} ({invoice.status})
</li>
))}
</ul>
</section>
);
}
No useEffect, no isLoading state, no client-side waterfall between "component mounted" and "data arrived." The request happens on the server, where you also have direct access to your database, internal service credentials, and anything else you'd never want to expose to a browser. That's a genuine security improvement over the old pattern of exposing a public API just so client components could call it.
The tradeoff is that this component is now server-only. It can't hold useState, can't respond to clicks, and can't re-fetch on an interval without a page navigation or an explicit revalidation. If part of that same UI needs interactivity, you compose a Client Component as a child and pass the fetched data down as props, which is exactly the boundary decision from the previous article.
Parallel fetching, and the waterfall you don't notice until it's slow
The most common production mistake in the App Router is sequential fetching: requests that could run at the same time but end up blocking each other instead. await reads top to bottom, so it's easy to write requests that depend on nothing but still block each other:
// Sequential: the second request waits for the first to finish for no reason
async function OrderSummary({ orderId }: { orderId: string }) {
const order = await getOrder(orderId);
const customer = await getCustomer(order.customerId);
const shipping = await getShippingStatus(orderId);
return <OrderDetails order={order} customer={customer} shipping={shipping} />;
}
If getCustomer needs order.customerId, that dependency is real and sequencing is correct. But getShippingStatus only needs orderId, which you already have. There's no reason it should wait for the customer lookup to resolve. Fix it by starting independent requests together and awaiting them once:
// Parallel: independent requests fire together
async function OrderSummary({ orderId }: { orderId: string }) {
const orderPromise = getOrder(orderId);
const shippingPromise = getShippingStatus(orderId);
const order = await orderPromise;
const customer = await getCustomer(order.customerId);
const shipping = await shippingPromise;
return <OrderDetails order={order} customer={customer} shipping={shipping} />;
}
Or, when nothing depends on anything else, Promise.all is the clearer way to express it:
async function OrderSummary({ orderId }: { orderId: string }) {
const [order, shipping] = await Promise.all([
getOrder(orderId),
getShippingStatus(orderId),
]);
const customer = await getCustomer(order.customerId);
return <OrderDetails order={order} customer={customer} shipping={shipping} />;
}
This matters more as your component tree grows. The waterfall problem compounds across nested Server Components. If a page renders five components, each fetching its own data sequentially, and each fetch takes a similar amount of time, the total time to first byte is roughly the sum of all of them, not the slowest one. It's easy to miss in development, where every request is fast against a local database. It's a lot harder to miss in production, once real network latency and a real database under load are involved.
Deduplicating requests with React's cache function
Next.js automatically deduplicates identical fetch calls made during the same render pass. If three different Server Components in the same tree call fetch("https://api.example.com/user/42") with the same options, Next.js only makes the request once and shares the result. That's convenient, but it only applies to fetch. It does not apply to your ORM, your Redis client, or any other data-access layer, which is most of what a real backend actually uses.
To get the same deduplication for non-fetch data sources, wrap the function with React's cache:
// lib/data/get-user.ts
import { cache } from "react";
import { db } from "@/lib/db";
export const getUser = cache(async (userId: string) => {
return db.user.findUnique({ where: { id: userId } });
});
Now, if Header, Sidebar, and ProfileCard all call getUser(userId) for the same request, the underlying database query runs once, and every caller gets the same in-flight promise. Without cache, each component would issue its own query, and the number of duplicate queries would scale with however many components happen to need the same piece of data, which tends to grow over time as a codebase does.
The scope of cache matters: it deduplicates within a single request/render, not across requests or across users. It is not a persistent cache, and it will not survive to the next page load. If you need caching that outlives a single render (an expensive aggregate query, a third-party API with a rate limit, a value that's fine to be a few seconds stale) that's a job for fetch's built-in caching options, a Redis-backed cache in front of your data layer, or Next.js's revalidation APIs, which the next article covers in depth.
Streaming with Suspense instead of blocking the whole page
Not every piece of data on a page is equally urgent. A dashboard's header and navigation should render immediately; a slow analytics widget shouldn't hold up everything else. The App Router lets you stream parts of the page independently by wrapping slower components in Suspense:
import { Suspense } from "react";
export default function DashboardPage({ orgId }: { orgId: string }) {
return (
<div>
<DashboardHeader />
<Suspense fallback={<AnalyticsSkeleton />}>
<AnalyticsWidget orgId={orgId} />
</Suspense>
</div>
);
}
async function AnalyticsWidget({ orgId }: { orgId: string }) {
const stats = await getSlowAnalyticsQuery(orgId);
return <AnalyticsChart data={stats} />;
}
The page shell and header ship to the browser right away, with a fallback in place of AnalyticsWidget. When the slow query resolves, Next.js streams the real content in and swaps it, without a client-side round trip or a spinner state you have to manage by hand. This is genuinely useful when one data source in a page is meaningfully slower than the rest, and it degrades gracefully: on a route without any particularly slow fetch, you simply don't need it.
The pitfall is reaching for Suspense everywhere as a default. Every boundary adds a loading state the user has to look at, and stacking several of them on one page can make a page feel more broken up than it needs to, especially if the underlying fetches are all roughly the same speed anyway. Use it only where there's an actual asymmetry in fetch time worth exposing to the user, rather than as a blanket pattern.
When a Server Component still isn't the right tool
None of this replaces client-side data fetching entirely. If data needs to update in response to user interaction without a full page navigation, poll on an interval, or come from a source the browser has better context for (geolocation, a websocket, form state), you still want a Client Component, and often still want something like React Query or SWR for cache invalidation, retries, and optimistic updates. The pattern that works well in production is: fetch the initial data on the server for fast, SEO-friendly first paint, and hand it to a Client Component as an initial value that a client-side fetching library takes over from there.
// Server Component: fetches initial data
async function TicketList({ orgId }: { orgId: string }) {
const initialTickets = await getTickets(orgId);
return <TicketListClient orgId={orgId} initialTickets={initialTickets} />;
}
"use client";
// Client Component: takes over for live updates
import { useQuery } from "@tanstack/react-query";
function TicketListClient({
orgId,
initialTickets,
}: {
orgId: string;
initialTickets: Ticket[];
}) {
const { data: tickets } = useQuery({
queryKey: ["tickets", orgId],
queryFn: () => fetchTickets(orgId),
initialData: initialTickets,
refetchInterval: 30_000,
});
return <TicketTable tickets={tickets} />;
}
This gives you the best of both without duplicating logic: the server does the expensive first fetch so the page isn't blank on load, and the client library owns the ongoing lifecycle once the user is actually interacting with the page.
Key takeaways
- Server Components are async functions; `await` your data source directly in the component body instead of reaching for `useEffect` and loading state.
- Watch for accidental waterfalls: `await` runs top to bottom, so start independent fetches together with `Promise.all` or by kicking off promises before awaiting them.
- `fetch` is deduplicated automatically within a render, but your database client, Redis client, or any other data-access layer is not; wrap those functions in React's `cache` to get the same behavior.
- `Suspense` lets you stream slower parts of a page independently, but overusing it turns one page into several visible loading states; reserve it for a genuine speed mismatch.
- Server-fetched initial data plus a client-side library like React Query is a solid pattern when a page needs both fast first paint and live client-side updates.
- Persistent caching (across requests, across users) is a separate concern from request deduplication; the next article covers `fetch` caching and revalidation directly.
Frequently asked questions
Do I still need useEffect for data fetching in the App Router?
For data a Server Component can access directly, no. Fetch it with `await` in the component body. `useEffect` is still the right tool for client-side concerns like responding to user interaction, subscriptions, or polling that has to run in the browser.
Does React's cache function persist data between requests?
No. It deduplicates calls with the same arguments within a single render pass, so multiple components asking for the same data trigger one underlying call instead of several. It does not survive past that render, and it is not shared across different users or requests.
Why does my page feel slower even though each individual fetch is fast?
Check for an accidental waterfall: fetches that don't depend on each other but are still awaited sequentially. Each one adds its latency on top of the last instead of running concurrently, and the effect compounds as more components are added to the tree.
Should I fetch data in a layout, a page, or both?
Fetch data as close as possible to the component that needs it, unless multiple children genuinely need the same data, in which case wrapping the fetching function in cache and calling it from each child is usually cleaner than threading props through layouts that don't otherwise need them.
When should I use a Route Handler instead of fetching directly in a Server Component?
Use a Route Handler when a client component needs to fetch or mutate data after the initial page load, when an external service (a webhook, a third-party integration) needs an endpoint to call, or when you need a stable API contract that isn't tied to a specific page's rendering.
Is Suspense required for every async Server Component?
No. Without a Suspense boundary, Next.js waits for all data in a route to resolve before sending any HTML, which is fine for pages where nothing is meaningfully slower than the rest. Add a boundary only around the parts that are slow enough to be worth showing separately.
Explore more on

About the author
I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.