Skip to content
JL

Home

About

Blog

Contact

Shop

Portfolio

Privacy

TOS

Click to navigate

  1. Home
  2. Joshua R. Lehman's Blog
  3. Data Fetching Composition in Next.js

Table of contents

  • Share on X
  • Discuss on X

Related Articles

Cache Components and use cache in Next.js
Next.js
12m
Oct 29, 2026

Cache Components and use cache in Next.js

Cache Components turn caching into an explicit architectural boundary. Learn when use cache helps, where runtime inputs belong, and why per-request memoization is a different tool.

#Next.js#Cache Components+4
Server and Client Components in Next.js
Next.js
11m
Sep 24, 2026

Server and Client Components in Next.js

Server and Client Components are a design boundary, not a contest between two rendering styles. Learn what crosses the boundary, how imports affect bundles, and how to keep interactive islands focused.

#Next.js#Server Components+4
Revalidation and Cache Invalidation in Next.js
Next.js
12m
Nov 5, 2026

Revalidation and Cache Invalidation in Next.js

Cache invalidation becomes manageable when it is designed as a freshness contract: identify data ownership, tag cached reads, choose user-visible consistency, and prove mutations invalidate the intended surface.

#Next.js#Cache Invalidation+4
Ask me anything! 💬

© Joshua R. Lehman

Full Stack Developer

Crafted with passion • Built with modern web technologies

2026 • All rights reserved

Contents

  • Start With the Dependency Graph
  • Start Independent Work Together
  • Keep Real Dependencies and Stream Their Cost
  • Colocate Data Access Without Duplicate Work
  • Choose Caching Separately From Composition
  • Key Takeaways
Next.js

Data Fetching Composition in Next.js

October 22, 2026•6 min read
Joshua R. Lehman
Joshua R. Lehman
Author
Centred data-fetching dependency graph flowing from parallel sources through a streaming boundary to a rendered Next.js page
Data Fetching Composition in Next.js

Data fetching becomes slow when the component tree accidentally decides the network schedule. One component awaits an account, a child awaits orders, and another child awaits recommendations. Each query may be individually fast, yet the user experiences their sum.

In the App Router, data-fetching composition means making the dependency graph intentional. Start independent work together. Keep genuinely dependent work sequential. Use Suspense and route boundaries so one slow region does not hide everything else. Then decide caching independently from those scheduling choices.

Start With the Dependency Graph

Before adding Promise.all, name what each query requires. A customer dashboard might need identity, account summary, orders, and recommendations:

route params ──> account
             ├─> summary
             ├─> orders
             └─> recommendations
 
account.id ──> saved payment methods

The first four can begin as soon as route parameters are known. Payment methods cannot begin until the account lookup supplies an internal id. That is a real dependency—not a performance mistake. The goal is not “everything parallel”; it is no accidental waiting.

The current Next.js fetching guide (opens in new tab) distinguishes exactly these shapes: sequential fetching is appropriate when the next request needs the earlier result, while parallel fetching eagerly starts independent requests. Layout and page segments render in parallel by default, but await placement inside one component can still serialize otherwise independent work.

Compare fetch schedules

Switch among common data-flow patterns. The displayed durations are illustrative, so focus on request dependencies rather than the exact numbers.

Fetch-composition simulator

Compare request start patterns; illustrative durations are not a benchmark.

const [account, orders, recommendations] = await Promise.all([ getAccount(id), getOrders(id), getRecommendations(id), ]);

max(900ms, 700ms, 500ms) ≈ 900ms before all results

Independent requests begin together; render or stream the results when ready.

const [account, orders] = await Promise.all([
  getAccount(id),
  getOrders(id),
]);

Start Independent Work Together

This sequential code reads naturally but starts one request only after the previous response has completed:

const profile = await getProfile(userId);
const orders = await getOrders(userId);
const alerts = await getAlerts(userId);

When each query is independent, initiate them together and await the group:

const profilePromise = getProfile(userId);
const ordersPromise = getOrders(userId);
const alertsPromise = getAlerts(userId);
 
const [profile, orders, alerts] = await Promise.all([
  profilePromise,
  ordersPromise,
  alertsPromise,
]);

Starting promises early is useful when some work becomes conditional later. It is not a license to fetch every possible thing. Each request has a cost, data-access policy, and failure mode. Start the work a route truly needs, only after authorization inputs are available, and make cancellation or error handling fit the product.

Use Promise.allSettled only when the product can genuinely render useful partial results and communicate the missing region honestly. It is not a way to hide a failed required query. A total balance screen with missing ledger entries should probably fail as a unit; a feed with optional “people to follow” suggestions may not need to.

Keep Real Dependencies and Stream Their Cost

Some waterfalls are correct. If getOrdersForAccount requires an account id that a public slug resolves to, do not speculate or manufacture an alternate query just to use Promise.all. Fetch the account, then allow dependent content to stream behind a focused boundary.

import { Suspense } from "react";
 
export default async function AccountPage({ params }: PageProps) {
  const { slug } = await params;
  const account = await getAccountBySlug(slug);
 
  return (
    <main>
      <AccountHeader account={account} />
      <Suspense fallback={<OrdersSkeleton />}>
        <RecentOrders accountId={account.id} />
      </Suspense>
    </main>
  );
}
 
async function RecentOrders({ accountId }: { accountId: string }) {
  const orders = await getRecentOrders(accountId);
  return <OrdersList orders={orders} />;
}

The page still awaits the account because its header depends on it. But the user receives that useful context while orders arrive. If the account is itself slow and nothing can render without it, a segment-level loading.tsx from the previous article gives immediate feedback. Boundaries should correspond to meaningful units of the experience, not arbitrary component depth.

Do Not Fetch Through Your Own Route Handler on the Server

A Server Component can call its database, SDK, or upstream API directly. Calling a Route Handler from that Server Component adds an unnecessary HTTP hop and can make authentication, caching, and error handling harder to reason about.

Colocate Data Access Without Duplicate Work

Prop drilling every shared record from the top of a page creates a different kind of coupling. Let a component request the data it owns, using a shared server-side function. For identical fetch requests in a React component tree, Next.js documents request memoization by default. For an ORM, CMS client, or other non-fetch function, wrap the read in React’s cache.

import { cache } from "react";
 
export const getAccount = cache(async (id: string) => {
  return db.account.findUniqueOrThrow({ where: { id } });
});

Now a layout and a page can both call getAccount(id) without a duplicate database query during one render. The memoization scope is per request, not a shared application cache. This distinction is important: it prevents repeated work inside a tree but does not keep yesterday’s account record for tomorrow’s visitor.

Keep data functions close to their authorization and selection policy. getVisibleAccount({ viewerId, accountId }) expresses more safety than a generic getAccount(accountId) that every caller must remember to filter afterward. Composition should reduce props, not erase access control.

Choose Caching Separately From Composition

There are two questions that often get conflated:

  1. Which requests can start together for this render?
  2. May a result be reused across renders or users, and for how long?

The first is composition. The second is caching. Next.js extends server fetch with persistent Data Cache controls such as cache, next.revalidate, and tags; see the current fetch reference (opens in new tab). React request memoization, by contrast, only deduplicates matching work for the current render lifecycle.

const catalog = await fetch("https://api.example.com/catalog", {
  next: { revalidate: 300, tags: ["catalog"] },
}).then((response) => response.json());

Choose cache policy from data semantics: freshness needs, sharing safety, invalidation source, and cost. Do not apply a long-lived cache to personalized account data simply because it reduced a dashboard waterfall. Conversely, a cached catalog still benefits from a well-composed page structure.

Newer Next.js applications using Cache Components can mark a function, component, or route with use cache; the directive documentation (opens in new tab) requires cacheComponents and explains that runtime inputs such as cookies should be read outside the cached scope and passed as arguments. Treat that as an explicit product decision, not a global performance switch.

Measure the Route You Built

Visual intuition is not enough. Trace the request start times, database spans, and Suspense reveal order for representative routes. Look for sequential work with no data edge between it, duplicated client/server fetches, and boundaries that hide useful content behind a single spinner.

Then test the experience under realistic latency:

  1. Delay one independent query and confirm other content still appears when it can.
  2. Delay a dependent query and confirm its local fallback has helpful shape and copy.
  3. Trigger a required-query error and confirm it reaches the appropriate error boundary.
  4. Verify a repeated shared read only executes once per render when memoization is expected.
  5. Check personalized and public data separately before adding persistent caching.

Key Takeaways

  • Model data dependencies before optimizing; intentional sequential work is not a bug.
  • Start independent requests together and use Promise.all only when all results are required as a unit.
  • Stream genuinely dependent regions with Suspense so slow secondary data does not hide primary context.
  • Colocate reads in components while using request memoization or React.cache to avoid repeated work in one render.
  • Treat parallel composition, per-request memoization, and persistent caching as separate tools with separate safety and freshness decisions.

Next, we will make those caching choices explicit with Cache Components and use cache, including the boundaries where runtime data and cached content must remain separate.