The old way: data down, components up
In the Pages Router, getServerSideProps fetched everything at the top and passed it down through props. This meant the page component knew about every piece of data the entire tree needed.
The new way: colocate data and UI
With Server Components, each component fetches its own data:
async function UserProfile({ userId }: { userId: string }) {
const user = await getUser(userId);
return <div>{user.name}</div>;
}
async function UserPosts({ userId }: { userId: string }) {
const posts = await getUserPosts(userId);
return (
<ul>
{posts.map(post => <li key={post.id}>{post.title}</li>)}
</ul>
);
}
Each component is self-contained. Remove UserPosts from the page and its data fetch disappears too.
The waterfall trap
If you nest async components, they serialize:
// This waterfalls — UserPosts waits for UserProfile to finish
async function Page({ userId }: { userId: string }) {
return (
<div>
<UserProfile userId={userId} />
<UserPosts userId={userId} />
</div>
);
}
Fix: fetch in parallel at the page level and pass the promises:
async function Page({ userId }: { userId: string }) {
const userPromise = getUser(userId);
const postsPromise = getUserPosts(userId);
return (
<div>
<UserProfile userPromise={userPromise} />
<UserPosts postsPromise={postsPromise} />
</div>
);
}
Both fetches start immediately. Each component awaits its own promise.
When to use Suspense boundaries
Wrap slow components in Suspense so the fast parts render immediately:
<Suspense fallback={<PostsSkeleton />}>
<UserPosts postsPromise={postsPromise} />
</Suspense>
The profile renders instantly. Posts stream in when ready.
What stays client-side
Anything with useState, useEffect, event handlers, or browser APIs. The rule is simple: if it reacts to user interaction, it is a Client Component. If it just renders data, keep it on the server.
