Web Development

Server components vs client islands in Next.js App Router

A practical breakdown of when to use server components and when to fall back to client islands in Next.js App Router, with concrete trade-offs and failure modes.

Mohammed Saqib9 min read
Detailed image of illuminated server racks showcasing modern technology infrastructure.
Photo by panumas nikhomkhai on Pexels · Pexels License

Server components are the default in Next.js App Router, but many teams treat them as a novelty rather than the primary rendering model. The result is a page that ships megabytes of JavaScript for static content, or a cascade of client boundaries that negate most of the framework's performance guarantees. Here is the decision framework I use across production apps, along with the failure modes I've hit when I got it wrong.

What server components and client islands actually are

A server component runs exclusively on the server during request time. It never ships JavaScript to the client—only its rendered HTML output. Because it never executes in the browser, it can directly access databases, filesystems, and environment variables without exposing connection strings or API keys to the client bundle. The component body is an async function—you can await a database query right in the render path.

A client island is the inverse: a component file marked with 'use client' at the top. This directive tells the bundler that this module and everything it imports (unless explicitly re-exported from a server component) should be included in the client-side JavaScript bundle. The component hydrates independently on the client, but it does not require the entire page to be client-rendered. You can have a server-rendered shell with a few interactive islands scattered inside it.

In App Router, the default for every file inside app/ is a server component. Adding 'use client' is an opt-out. This is the opposite of Pages Router, where every component was client-side by default. The mental model shift is deliberate: you start with the assumption that nothing needs interactivity, and only carve out the parts that do.

// app/products/page.tsx — server component by default
export default async function ProductsPage() {
  const products = await db.query('SELECT * FROM products LIMIT 20');
  return (
    <div>
      {products.map(p => (
        <ProductCard key={p.id} product={p} />
      ))}
    </div>
  );
}
// app/products/AddToCartButton.tsx — client island
'use client';
import { useState } from 'react';
 
export function AddToCartButton({ productId }: { productId: string }) {
  const [added, setAdded] = useState(false);
  return (
    <button onClick={() => { setAdded(true); /* POST to cart */ }}>
      {added ? 'Added' : 'Add to cart'}
    </button>
  );
}

When to use server components

Use a server component whenever the rendered output does not need to respond to user interaction or change after the initial page load. This covers most content-oriented UI: product listings, article bodies, user profiles, dashboard charts (as long as the charting library supports server-side rendering), navigation menus without client-side state, and footer content.

The strongest case for server components is data fetching. Because they run on every request (or at build time for static generation), you can query your database or read from a CMS directly without building an API route. This eliminates the fetch-from-API roundtrip that Pages Router required. The data is fetched once, on the server, and serialized into the RSC payload.

// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';
 
export default async function BlogPost({
  params
}: {
  params: { slug: string }
}) {
  const post = await db.post.findUnique({
    where: { slug: params.slug },
    include: { author: true }
  });
 
  if (!post) notFound();
 
  return (
    <article>
      <h1>{post.title}</h1>
      <p className="text-sm text-gray-500">By {post.author.name}</p>
      <div dangerouslySetInnerHTML={{ __html: post.bodyHtml }} />
    </article>
  );
}

Do not try to use event handlers, useState, or lifecycle hooks inside a server component. The compiler will throw a clear error, but the pattern I see is people accidentally pulling in a client-side dependency through an import chain and wondering why their "server component" is shipping JavaScript. More on that below.

When to reach for client islands

Any component that needs useState, useEffect, useRef, or a browser-only API like localStorage, IntersectionObserver, or window.innerWidth must be a client component. This includes form inputs with live validation, modals, dropdowns, tooltips, drag-and-drop interfaces, and anything using onClick, onChange, or onSubmit that does not send a full form POST.

Third-party libraries that directly manipulate the DOM or use React Context without a server-compatible provider force a client boundary. For example, wrapping your app in a theme provider from next-themes or an auth provider from @clerk/nextjs requires a client component because they rely on React Context, which is not supported in server components. The standard pattern is to create a Providers client component that wraps children, then import it in your root layout.

// app/providers.tsx
'use client';
import { ThemeProvider } from 'next-themes';
import { ClerkProvider } from '@clerk/nextjs';
 
export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <ClerkProvider>
      <ThemeProvider attribute="class">
        {children}
      </ThemeProvider>
    </ClerkProvider>
  );
}

Forms with client-side validation are another common client island. If you want instant feedback on email format or password strength without waiting for a server roundtrip, the form component needs useState to track field errors. The server action still handles submission, but the validation UI lives on the client.

Choosing between the two: a concrete decision flow

The following table summarises the trade-offs I use when deciding where to place a component.

Criterion Server component Client island
Needs hooks or event handlers No — compiler error Yes — required
Fetches data per request Yes — direct await No — must use useEffect or SWR
Reads from database/filesystem Yes — direct access No — must call an API
Ships JavaScript to client Never Always
Can use async/await in body Yes No (must use .then() or a hook)
Supports React Context No Yes
Ideal for Static content, data fetching Interactive UI, forms, third-party widgets

The decision flow is a short checklist:

  1. Does this component handle events or use hooks? If yes, make it a client island. If no, proceed.
  2. Does it fetch data that changes per request? If yes, keep it a server component—you avoid the waterfall of client-side fetching. If the data is static across all users, consider generating it at build time with generateStaticParams instead.
  3. Can I extract the interactive part into a small wrapper that accepts server-rendered children? This is the most powerful pattern. Wrap the interactive shell around static content. The outer shell is a client island, but the children remain server components. For example, a client-side accordion that takes server-rendered panels as children.
// app/components/Accordion.tsx
'use client';
import { useState } from 'react';
 
export function Accordion({ children }: { children: React.ReactNode }) {
  const [open, setOpen] = useState(false);
  return (
    <div>
      <button onClick={() => setOpen(!open)}>Toggle</button>
      {open && <div>{children}</div>}
    </div>
  );
}

The children passed to Accordion can be server components—they never become client code.

Failure modes: when your strategy breaks

The most common mistake is accidentally pulling a server component into the client bundle. If a server component imports a module that contains 'use client' anywhere in its dependency tree, the importing server component becomes a client component too. This is called a "client boundary leak." The result is your entire page, or large sections of it, end up in the JavaScript bundle. I've seen a production app where a single 'use client' import in a layout caused 400KB of component code to ship to every page.

The fix is to keep client islands small and deliberately placed. Never import a client component directly from a server component unless you intend the entire file to become client-rendered. Use composition: pass client components as children or props, and keep the data-fetching and layout logic in the server component.

Client components cannot use async/await directly in the component body. If you need to fetch data inside a client island, you must use useEffect or a data-fetching library like SWR or TanStack Query. This introduces a client-side waterfall: the component mounts, fetches data, then re-renders. Server components avoid this entirely by fetching during the request.

Third-party context providers are another common pitfall. If you wrap a server component in a <ThemeProvider> or <AuthProvider>, the server component will not be able to consume that context—it does not exist on the server. The provider must be a client island, and any component that reads from that context must also be a client island. The standard pattern is to wrap the root layout in the provider, but keep the rest of the layout as a server component.

Performance implications you should measure

Server components reduce JavaScript bundle size by eliminating the serialized HTML and hydration step for static content. The actual reduction depends on how much of your page is interactive. For a marketing site with a hero section, blog content, and a footer, you can expect 50-70% less JavaScript compared to a Pages Router equivalent where everything was client-rendered. Measure the difference in total byte transfer and Time to Interactive (TTI) in Lighthouse or WebPageTest.

Client islands increase the number of hydration points. If you have fifty small islands (each dropdown, each tooltip is its own 'use client' file), the browser must hydrate each one independently. Too many small islands can cause jank if they all hydrate simultaneously on page load. Use next/dynamic with ssr: false for heavy islands that don't need initial HTML—this defers their hydration until they are visible or the main thread is idle.

import dynamic from 'next/dynamic';
 
const HeavyChart = dynamic(() => import('./HeavyChart'), {
  ssr: false,
  loading: () => <div className="h-96 bg-gray-100 animate-pulse" />
});

The RSC payload is not free. For each request, the server sends a JSON-like stream that the client parses and uses to reconstruct the component tree. On slow networks or for very large pages with hundreds of server components, this payload can delay the first paint compared to a fully static HTML file. I've seen a product listing page with 200+ server components produce an RSC payload of 150KB of JSON. The client must parse that before it can render anything. For pages that could be fully static (no per-request data), consider generating static HTML at build time instead of relying on server components.

I wrote about a related rendering concern in Adopting React Native New Architecture: Fabric and TurboModules—the same principle of measuring actual payload cost applies across runtimes.

The React documentation on server components provides a detailed explanation of the rendering lifecycle. The Next.js documentation on client boundaries explains the exact rules for when a file becomes client-rendered.

Key takeaways

  • Start with server components by default. Only add 'use client' when a component needs hooks, event handlers, or browser-only APIs.
  • Extract interactive wrappers around server-rendered children to keep static content off the client bundle.
  • Measure the RSC payload size and client bundle size—server components are not free, and large payloads can hurt perceived performance on slow connections.
  • Use next/dynamic with ssr: false for heavy client islands that do not need initial HTML.
  • Watch for client boundary leaks: importing a client component from a server component silently turns the server component into a client component.

Frequently asked questions

How do I know if my component crosses the client boundary?
A client boundary is any file that imports `'use client'` at the top, or a component that imports from a module that has that directive. Once a component is marked as a client component, all its children run on the client unless they are explicitly rendered as server components via composition (passed as props).
How do I keep most of a page as a server component while adding interactivity?
Wrap the interactive part in a small client component and pass the server-rendered data as children or props. The server component fetches data and renders the static wrapper; the client island handles the click, scroll, or form state without pulling the entire tree into JavaScript.
Can a server component use hooks if it doesn't import 'use client'?
Yes — if the server component uses `useState` or `useEffect` directly. `'use client'` is required for hooks and browser APIs. Also, any module that imports a client component without being marked itself will cause the bundler to hoist the client boundary up, potentially increasing the bundle size more than expected.
#nextjs#server-components#client-components#rendering#react
Share

Keep reading