• nextjs
  • react
  • performance

How Server Components changed the way I fetch data

React Server Components are not just a performance optimization. They are a different mental model for where code lives.

Jan 28, 2026·6 min read

For most of my React career, data fetching looked the same: a useEffect with a fetch inside it, a loading state, an error state, and a prayer that the component unmounted cleanly.

Server Components did not improve this pattern. They made it irrelevant.

#The shift in mental model

The distinction is not "server" vs "client" as in where the button click handler runs. It is about where the work happens.

A Server Component runs once, on the server, during the request. It has direct access to databases, file systems, environment variables, and internal APIs. It does not ship to the browser. It does not add to the bundle. It renders to HTML (or a serialized tree), and that is the end of its lifecycle.

app/blog/[slug]/page.tsx
// This component never runs in the browser.
// No useEffect. No loading spinner. No client bundle.
export default async function PostPage({ params }: { params: { slug: string } }) {
  const post = await db.posts.findBySlug(params.slug);
 
  if (!post) notFound();
 
  return <article>{post.content}</article>;
}

The await db.posts.findBySlug call is a direct database query. No API route. No fetch. No network hop.

#Async/await is the API

Server Components are async functions. That is the entire data-fetching API. You await what you need, and you render with it. The mental model is closer to writing a script than writing a React component.

This is where the real benefit shows up: you can compose data fetching the same way you compose components.

app/dashboard/page.tsx
export default async function Dashboard() {
  return (
    <main>
      <Suspense fallback={<StatsSkeleton />}>
        <Stats />
      </Suspense>
      <Suspense fallback={<FeedSkeleton />}>
        <Feed />
      </Suspense>
    </main>
  );
}
 
async function Stats() {
  const stats = await fetchStats(); // runs in parallel with Feed's fetch
  return <StatsGrid data={stats} />;
}
 
async function Feed() {
  const items = await fetchFeed();
  return <FeedList items={items} />;
}

Stats and Feed each fetch their own data. Because they are wrapped in Suspense, they stream independently — whichever finishes first renders first. The dashboard page itself has no data-fetching logic.

#What stays on the client

Server Components do not handle events. No onClick, no useState, no useEffect. The moment you need interactivity, you need a Client Component.

The pattern that works well: Server Components for data and layout, Client Components as leaves for the interactive parts.

app/post/[slug]/page.tsx
export default async function PostPage({ params }) {
  const post = await getPost(params.slug);
  return (
    <article>
      <h1>{post.title}</h1>
      <PostContent content={post.content} />
      <LikeButton postId={post.id} initialCount={post.likes} />
    </article>
  );
}
components/LikeButton.tsx
'use client';
 
export function LikeButton({ postId, initialCount }: LikeButtonProps) {
  const [count, setCount] = useState(initialCount);
  return <button onClick={async () => { /* server action */ }}>{count} likes</button>;
}

The LikeButton is a small island of interactivity inside a largely static page.

#The waterfall problem is still real

Server Components serialize by default. If you await two queries sequentially in the same component, you get a waterfall:

// sequential — slow
const user = await getUser(id);
const posts = await getUserPosts(id); // waits for user before starting

Start them together:

// parallel — fast
const [user, posts] = await Promise.all([getUser(id), getUserPosts(id)]);

Or delegate to child components wrapped in Suspense, which Next.js fetches in parallel naturally.

Async/await makes waterfalls invisible. Promise.all and Suspense boundaries are the antidote.

#What surprised me

I expected to miss the ergonomics of client-side fetching. I do not. What I miss is the mental overhead — the loading states, the error boundaries, the cache invalidation puzzles. Most of that is just gone for the 80% of pages that are reading data, not writing it.

The 20% that writes — forms, mutations, realtime updates — still needs useState and careful cache management. But isolating that 20% as actual Client Components makes it easier to reason about, not harder.


Server Components did not replace client-side React. They gave it a better job description: handle interaction, not initialization.