Server-first data fetching in Next.js App Router
Fetch data in Server Components and keep 'use client' limited to interactive leaf components to avoid client-side waterfalls and serialization errors.
05 Jul 2025, 16:31 UTC

The slow product page
You open a product listing page in a Next.js App Router project and see a blank shell before the list appears. The page file starts with 'use client', and the component fetches products inside useEffect. The browser downloads the page component, renders it, then fetches data, then hydrates. That sequence is a client-side waterfall, and it ships page component code to the browser even when nothing on the page needs browser APIs.
The fix is usually to remove 'use client' from the page, fetch data directly in an async Server Component, and isolate interactivity—such as an Add to Cart button—in a small client component at the leaf of the tree.
What server by default means in app/
Files under app/ are React Server Components by default. A React Server Component renders on the server and does not send its component code to the browser. It can be async, so it can read a database, filesystem, or mock data source directly. Because rendering happens on the server, the browser receives HTML without waiting for a client-side fetch.
Hydration is the process where React attaches event listeners to server-generated HTML in the browser. If the page never needs event listeners, there is little reason to send its component code to the client at all.
Draw the boundary at 'use client'
Add 'use client' only when a component needs browser APIs, React state, event handlers, or effects. Everything imported by a client component becomes part of the client bundle. Do not put 'use client' on a layout unless necessary, because it opts the whole subtree into client rendering and can increase JavaScript bundle size.
Props passed from a Server Component to a Client Component must be serializable. Keep the boundary data shaped as plain JSON-compatible objects. Functions, class instances, Date objects, and DOM nodes are not reliably transferred across the boundary.
Worked example: product list with an add-to-cart button
This example keeps the page on the server and moves only the button to the client.
app/products/page.tsx
import { products } from '@/lib/products';
import AddToCartButton from './AddToCartButton';
export default async function ProductsPage() {
const list = products;
return (
{list.map((item) => (
-
{item.name} - {item.price}
))}
);
}
app/products/AddToCartButton.tsx
'use client';
import { useState } from 'react';
export default function AddToCartButton({ id }: { id: string }) {
const [added, setAdded] = useState(false);
async function handleClick() {
await fetch('/api/cart/' + id, { method: 'POST' });
setAdded(true);
}
return (
{added ? 'Added' : 'Add to cart'}
);
}
lib/products.ts
export type Product = { id: string; name: string; price: number };
export const products: Product[] = [
{ id: 'p-1', name: 'Widget', price: 10 },
{ id: 'p-2', name: 'Gadget', price: 20 },
];
The page passes only id, a string, across the server/client boundary. That is serializable. The button handles click and state in the browser, while the list markup arrives from the server.
Verification steps
Run these in your project root. No special permissions are required.
- Add
console.log('rendering products page on server')insideProductsPage. Runnext dev. Confirm the log appears in the terminal running the dev server, not in the browser console. - Confirm the
AddToCartButtonhydrates in the browser while the parent page remains a Server Component. You can check this in React DevTools by looking for the client component badge on the button only. - Pass a
Datefrom the server page to the client button and runnext build. Expect a serialization error. Replace it with an ISO string or an id. - Run
next --versionand read the release notes for your installed release. Fetch caching and router cache defaults changed across Next.js major versions. Setcache: 'no-store'ornext.revalidateexplicitly if behavior must not change between releases.
Risk: next build fails when non-serializable props cross the boundary. Risk: assuming indefinite caching can cause stale data after an upgrade.
Limitations
- Server Components cannot use
useState,useEffect, refs,window, ordocument. Move those into a client component leaf. - Non-serializable props fail at build time or runtime.
- Placing
'use client'on a layout opts the entire subtree into client rendering. - Fetch caching defaults are version-sensitive; do not assume behavior without checking your Next.js release notes.
Practical check
Open browser DevTools to the Network tab and reload the product page. There should be no client-side fetch for the initial product list. The server log should appear in your terminal during render or build. If you see a client-side fetch for data that could have been fetched on the server, move that fetch up into the Server Component.
Closing
Start with the page as a Server Component. Fetch where the data lives. Add 'use client' only to the smallest interactive piece, and pass it plain IDs or strings. That keeps the initial HTML fast and the client bundle small.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.