Skip to content
All articles

React & Next.js · Frontend

Server or Client Component? A practical guide for the Next.js App Router

When a component needs "use client", how to keep the client boundary small, and the children pattern that lets server content live inside client wrappers.

Abdul Nafay 2 min read

In the Next.js App Router, every component is a Server Component unless you opt out with the "use client" directive. Server Components render on the server and send HTML — no JavaScript for that component reaches the browser. Client Components are hydrated in the browser so they can be interactive.

Deciding between them comes down to one question: does this component need the browser?

When you need "use client"

Add the directive when a component uses any of these:

  • State and lifecycle hooks: useState, useReducer, useEffect.
  • Event handlers such as onClick or onChange.
  • Browser-only APIs: window, localStorage, IntersectionObserver.
  • Libraries that rely on any of the above, such as animation or chart libraries.

Everything else — layout, text, fetching data, reading from a database or CMS — can stay on the server.

Push the client boundary down

"use client" marks a boundary: the component and everything it imports become part of the client bundle. Putting it at the top of a page ships the whole page as JavaScript. Instead, keep the page on the server and make only the interactive leaf a Client Component.

typescript
// app/blog/[slug]/page.tsx — Server Component
import { LikeButton } from './LikeButton'

export default async function Post({ params }) {
  const post = await getPost((await params).slug) // runs on the server
  return (
    <article>
      <h1>{post.title}</h1>
      <div>{post.body}</div>
      <LikeButton postId={post.id} />
    </article>
  )
}
typescript
// app/blog/[slug]/LikeButton.tsx — Client Component
'use client'
import { useState } from 'react'

export function LikeButton({ postId }: { postId: string }) {
  const [liked, setLiked] = useState(false)
  return (
    <button aria-pressed={liked} onClick={() => setLiked(!liked)}>
      {liked ? 'Liked' : 'Like'}
    </button>
  )
}

Only the button ships JavaScript; the article stays as plain HTML.

Props crossing the boundary must be serialisable

Data passed from a Server Component to a Client Component is serialised. Strings, numbers, booleans, plain objects, arrays and dates are fine. Functions and class instances are not — so pass an ID, not a database record with methods attached.

The children pattern

A Client Component can’t import a Server Component, but it can render one passed in as children. This is how you wrap server-rendered content in client behaviour, like an animated container or a modal.

typescript
'use client'
import { useState, type ReactNode } from 'react'

export function Collapsible({ title, children }: { title: string; children: ReactNode }) {
  const [open, setOpen] = useState(false)
  return (
    <section>
      <button aria-expanded={open} onClick={() => setOpen(!open)}>{title}</button>
      {open && children}
    </section>
  )
}

The page can now render <Collapsible title="Details"><ServerRenderedDetails /></Collapsible> — the details are still rendered on the server.

A quick checklist

  1. Start every component as a Server Component.
  2. Add "use client" only when it needs state, effects, events or browser APIs.
  3. Move the directive to the smallest interactive piece.
  4. Pass serialisable data across the boundary.
  5. Use children to put server content inside client wrappers.

Contact

Have a project or a role in mind? Let’s talk.

Send a message through the contact form, or email me directly.