Skip to Content
All posts

From Hydration Drag to Fast Pages: My Small Wins with Server Components

 — #nextjs#react#performance

I used to measure page speed the wrong way: open DevTools, click refresh, curse at a long "hydration" period, and then reach for bigger bundles or complex caching. After a few painful deploys and a clearer look at where time was going, I realized the real win wasn't magic tooling — it was moving as much as possible off the client and being deliberate about the little interactive islands that remained.

Here are the practical rules I ended up following and how they helped.

1) Start with the expensive parts

If a component doesn't need event handlers, timers, or browser-only APIs, render it on the server. That immediately removes its hydration cost.

Example: a blog post where the header, markdown renderer, and related links can be server-rendered, while the comment box remains interactive.

2) Keep client islands tiny

Instead of wrapping a whole page in a client bundle, isolate the bits that actually need JS. Smaller islands = less JS to hydrate.

Client component example:

// components/LikeButton.client.jsx
'use client'
import { useState } from 'react'
 
export default function LikeButton({ initial }) {
  const [count, setCount] = useState(initial)
  return (
    <button onClick={() => setCount(c => c + 1)}>
      ❤️ {count}
    </button>
  )
}

Then import it into a server component:

// app/post/[slug].jsx (server component)
import LikeButton from '../../components/LikeButton.client'
export default function Post({ params }) {
  const post = await getPost(params.slug) // server-side fetch
  return (
    <>
      <h1>{post.title}</h1>
      <article dangerouslySetInnerHTML={{ __html: post.html }} />
      <LikeButton initial={post.likes} />
    </>
  )
}

Note: the .client suffix (or use client) makes intent explicit. The rest of the page stays server-rendered.

3) Prefer server data fetching for static content

When content doesn't need to be live, fetch it on the server and cache aggressively. This reduced my bundle size and eliminated duplicated fetching logic across client components.

4) Be pragmatic with interactivity

I used optimistic UI for small actions (likes, toggles) and delegated heavy state or subscriptions to dedicated client modules. If a feature becomes large and interactive (e.g., a real-time chat), make it a focused client app within the page, not a blanket client conversion.

5) Measure the right things

Look at "Time to Interactive" and "Total Blocking Time" and — crucially — the hydration waterfall in the Performance tab. After moving most UI to the server, my hydration times dropped and TTI improved without touching third-party scripts.

Small, focused changes compound. Moving one big table or list to render on the server often gives more improvement than trimming random imports.

If you're migrating an existing app, don't try to convert everything at once. Start with the parts that add the most JavaScript and move outward. The payoff is less cognitive overhead for your users and fewer build-time surprises for you.