Colocating Data Fetching in Server Components
Fetching data on the server, next to the component that needs it, so the initial page renders without the browser making its own round of API requests.
View the live demo →Concept: colocation
Keep the data requirement next to the component that uses it
Colocating data fetching in Server Components means keeping a component's data requirements close to the component itself, instead of fetching everything at the page level and passing it down through props. The component that renders the numbers is also the component that asks for them.
Because it's a Server Component, that fetch runs on the server: the browser receives finished UI rather than a list of things to go and fetch.
Browser (client-side fetching):
render empty page → run JavaScript → request /api
→ server queries database → data returns → render data
Server Component:
fetch data → render UI → BrowserStep 1: without colocation
The page fetches everything and hands it down
Here is the demo dashboard written the centralized way. The page fetches all the data and passes it to presentational children:
// app/dashboard/page.tsx
export default async function DashboardPage() {
const owner = await getWorkspaceOwner();
const metrics = await getWorkspaceMetrics("30d");
const activity = await getRecentActivity(4);
return (
<>
<UserHeader owner={owner} />
<MetricsGrid metrics={metrics} />
<RecentActivity activity={activity} />
</>
);
}Problems:
- The page knows every child's data requirements.
- Adding a new component means changing the page.
- Data fetching becomes centralized in one growing function.
- Every request must finish before the page renders any of its children. In the demo, metrics take about 0.9s and activity about 2.1s, so nothing appears until the slowest one lands.
Step 2: with colocation
Each Server Component owns its data requirements (recommended)
Flip it around. The page only decides what appears and where; each section is an async Server Component that fetches what it renders, wrapped in its own <Suspense> boundary:
// app/dashboard/page.tsx
export default function DashboardPage() {
return (
<Dashboard>
<Suspense fallback={<LoadingSpinner text="Loading metrics..." />}>
<MetricsGrid period="30d" />
</Suspense>
<Suspense fallback={<LoadingSpinner text="Loading recent activity..." />}>
<RecentActivity limit={4} />
</Suspense>
</Dashboard>
);
}// components/patterns/colocating-server-data/MetricsGrid.tsx
export async function MetricsGrid({ period }: { period: MetricsPeriod }) {
const { data: metrics } = await getWorkspaceMetrics(period);
return (
<div className="grid grid-cols-4 gap-4">
<Card label="Deploys shipped" value={metrics.deploysShipped} />
<Card label="Incidents resolved" value={metrics.incidentsResolved} />
{/* ... */}
</div>
);
}
// components/patterns/colocating-server-data/RecentActivity.tsx
export async function RecentActivity({ limit }: { limit: number }) {
const { data: activity } = await getRecentActivity(limit);
return (
<ul>
{activity.map((entry) => (
<li key={entry.id}>{entry.actor} {entry.action} {entry.target}</li>
))}
</ul>
);
}Now the two components fetch independently, and their Suspense boundaries let the UI stream as each one finishes: the fallback ships with the initial response, then metrics replace theirs at ~0.9s and activity at ~2.1s. The page no longer knows what either section needs, and adding a third is a new component, not a change to a central loader.
Dashboard
├── UserHeader → owner (prop from the page)
├── MetricsGrid → getWorkspaceMetrics(period) ~0.9s
└── RecentActivity → getRecentActivity(limit) ~2.1sStep 3: what about caching?
Colocation and caching are separate concerns
Colocation decides where a fetch is called. It says nothing about how often the work behind it runs. Each tool below does one job, and they compose:
| Pattern | Purpose |
|---|---|
| Server Component | Fetch data where it's needed |
| Suspense | Stream UI while data loads |
cache() | Deduplicate calls within a single server request |
"use cache" | Cache eligible results across requests |
| TanStack Query | Manage interactive client-side server state |
For example, when several Server Components need the same record, wrap the data-access function in React's cache():
// features/products/product.queries.ts
import { cache } from "react";
export const getProduct = cache(async (id: string) => {
return productService.getById(id);
});Both components can call getProduct("123") and share one result within the same server render. The Multi-Layer Caching pattern covers the cross-request layers in depth.
Concept: client boundary
Rendering on the server, interactivity on the client
Server Components do data access, transformation, and the initial render; Client Components, the ones marked "use client", own state, event handlers, and browser APIs. Push that boundary as low as it'll go: in the demo the period control is the single client component, and the dashboard around it stays on the server.
Server Component ── data + rendered UI ──▶ sent to the browser
│
└─▶ PeriodPicker ("use client")
state · events · browser APIsBecause the data work stays on the server, database queries, authentication checks, and credentials never reach the browser, and there's no browser-facing API layer or CORS to stand up in front of them.
Concept: knowing where to stop
Colocate the requirement, not the business logic
Keep business logic and database access in your service layer. A component should call getWorkspaceMetrics, not contain the query or the aggregation behind it. Shared queries stay reusable server-side functions; colocation is about where a fetch is called, not about duplicating the query.
Not every tiny component should fetch on its own. When several components need identical data, fetch it once at their nearest common boundary (or share a function wrapped in cache()) to avoid unnecessary database work. The demo does exactly this: UserHeader takes the owner as a prop and never fetches, because the page needs that record up front.
Colocate data requirements with the Server Component that consumes them, but keep business logic and database access in your existing service layer.
Implementation example
The dashboard demo's structure
Here's how the live demo fits together: a colocated data layer, a Server Component page that fetches the shared record and delegates the rest, three presentational sections, and one client component for the only interactive control.
app/patterns/colocating-server-data/
├── data.ts ← in-process graphql-yoga schema + queries
└── dashboard/
├── layout.tsx ← static shell: header, workspace name
└── page.tsx ← fetches the owner, wraps the slow sections
components/patterns/colocating-server-data/
├── UserHeader.tsx (prop-driven, no fetch)
├── MetricsGrid.tsx (server fetch: getWorkspaceMetrics)
├── RecentActivity.tsx (server fetch: getRecentActivity)
└── PeriodPicker.tsx ("use client" — the metrics window)
components/patterns/loading-spinner.tsx ← shared Suspense fallbackThe data layer speaks GraphQL. data.ts builds a small executable schema with graphql-yoga's createSchema and runs real queries against it, the in-process twin of the site's graphqlRequest against /api/graphql, so the demo needs no database to run.
Each section fetches what it renders. MetricsGrid and RecentActivity call their own data-access functions on the server, each behind its own Suspense boundary.