Skip to content

Server Components vs Client Components ​

Default in App Router: server. You opt into the browser with 'use client'. Feels backwards if you just learned hooks - until the bundle size drops and it clicks.

The default: Server Component ​

tsx
// app/posts/page.tsx
async function getPosts() {
  const res = await fetch('https://jsonplaceholder.typicode.com/posts', {
    next: { revalidate: 60 },
  })
  return res.json()
}

export default async function PostsPage() {
  const posts = await getPosts()
  return (
    <ul>
      {posts.slice(0, 5).map((p: { id: number; title: string }) => (
        <li key={p.id}>{p.title}</li>
      ))}
    </ul>
  )
}

Comments for the code above:

  • async page component - data loads on the server
  • No useEffect, no loading spinner state unless you want loading.tsx
  • This code does not need 'use client' - and shouldn't have it

When you need the client ​

Hooks (useState, useEffect), browser APIs, event handlers that aren't just <form action={…}> - you need a Client Component:

tsx
// components/LikeButton.tsx
'use client'

import { useState } from 'react'

export function LikeButton() {
  const [likes, setLikes] = useState(0)
  return (
    <button type="button" onClick={() => setLikes((n) => n + 1)}>
      Likes: {likes}
    </button>
  )
}
tsx
// app/posts/page.tsx (Server Component)
import { LikeButton } from '@/components/LikeButton'

export default async function PostsPage() {
  const posts = await getPosts()
  return (
    <div>
      <PostList posts={posts} />
      <LikeButton />
    </div>
  )
}

As you can see - keep the page on the server, drop a small interactive island in. Nuxt islands / client components itch the same spot.

Rules that save you pain ​

  1. 'use client' marks a boundary - that file and its imports become a client bundle (transitive)
  2. You can import a Client Component into a Server Component
  3. You cannot import a Server Component into a Client Component (pass as children instead)
  4. Don't slap 'use client' on the root layout "just to make hooks work" - push it down

Pass children through the boundary ​

tsx
// ClientShell.tsx
'use client'
export function ClientShell({ children }: { children: React.ReactNode }) {
  return <div className="shell">{children}</div>
}

// Server page
<ClientShell>
  <ExpensiveServerStuff />
</ClientShell>

children can still be rendered on the server and passed in. Handy pattern.

Vue brain check ​

InstinctNext reality
Everything is a Vue SFC that hydratesOnly client components hydrate
useFetch in setup everywherePrefer server fetch in RSC
Pinia for server dataOften cache on server + Query on client islands

Next: caching / revalidation without mythology.

Best,

-- ł.