Engineering

Next.js Server vs Client Components: Where to Draw It

Abhishek Bahukhandi

Abhishek Bahukhandi

•10 min read
Taqari cover art for a deep dive on Next.js server and client component boundaries
Taqari cover art for a deep dive on Next.js server and client component boundaries
Next.js server vs client components decided in a real codebase: where we put the boundary, what generateStaticParams prerenders, and the bundle we measured.

Choosing between Next.js server vs client components is not a style question. It decides what lands in the browser's JavaScript bundle, and the App Router makes the decision for you by default: every component is a server component until a file says otherwise. This is a walk through where we actually drew that line on the Taqari blog, and the numbers our own build prints for it.

The blog is a good specimen because the data is embarrassingly large and the interactivity is embarrassingly small. One module holds every published article. One scroll bar needs client state. Getting the boundary right between those two facts is the whole job.

Every component is a server component until you opt out

The default is the part people miss. In the App Router you do not add anything to get a server component; you add something to stop getting one. That inversion is deliberate — the original React Server Components RFC, opened by Joseph Savona and merged in October 2022, lists reduced bundle size and direct backend access as the headline benefits, and settled on a 'use client' directive to mark the components that opt back out.

What the directive actually marks

It marks an entry point, not a file. Once a module is client code, everything it imports is client code too. This is why the directive belongs as deep in the tree as the interactivity does: put it on a top-level layout and you have just converted the whole subtree, including the parts that were happily rendering to static HTML.

Our root layout stays a server component for this reason. It owns fonts, metadata and the site-wide JSON-LD, and it wraps children in a small client Providers component that holds the Redux store and the toaster. The provider is client code because it has to be. The layout around it is not.

Server vs client components on the blog route

The route is two files. src/app/Blogs/[slug]/page.jsx is a server component and does the heavy work. BlogPostClient.jsx starts with 'use client' and does almost nothing.

The server half: data, schema, and finished HTML

The page component looks up the post, renders the body, and assembles structured data:

export default async function BlogPostPage({ params }) {
  const { slug } = await params;
  const post = getBlogPost(slug);
  if (!post) notFound();

  const body = renderPostContent(post.content);   // strip stray h1, add heading ids
  // ...assemble a cross-linked @graph of six schema types

  return (
    <>
      <JsonLd data={{ '@context': 'https://schema.org', '@graph': graph }} />
      <BlogPostClient post={/* flat, serializable fields */} html={body} toc={getTableOfContents(body)} />
    </>
  );
}

Note the await params. Next.js 15 made the request-time APIs asynchronous — the upgrade guide lists params, searchParams, cookies() and headers() among them. It is a small change that bites once, in both the page and generateMetadata.

Two details matter more than they look. renderPostContent runs on the server, so the regex pass that strips a stray h1 and injects heading ids never ships. And JsonLd has no directive at all — it is a plain server component that emits a script tag, so none of the schema-building code reaches the browser either.

The client half: one piece of state

The client component is presentational. Its own comment says so, and its entire reason for existing is this:

const [scrollProgress, setScrollProgress] = useState(0);

useEffect(() => {
  const handleScroll = () => { /* ...scrollY / totalHeight */ };
  handleScroll();
  window.addEventListener('scroll', handleScroll, { passive: true });
  return () => window.removeEventListener('scroll', handleScroll);
}, []);

It needs window and it needs state, so it has to be client code. Everything else it does is render the props it was handed, including dangerouslySetInnerHTML={{ __html: html }} for the article body.

That last choice is the one that pays. The obvious version of this component takes post and reads post.content itself. Do that and the data module joins the client graph.

The index route has no client half at all

The listing page at src/app/Blogs/page.jsx never crossed the boundary. It sorts the posts, picks a featured one, emits a Blog schema object and renders a grid of next/link cards. All of that is knowable at build time, so the route exports a plain metadata object and a plain function, with no directive anywhere. The build prices it at 194 B of route-specific JavaScript — effectively the shared baseline and nothing more.

That is the shape to aim for whenever a page is a list of links. The moment someone adds a category filter with useState, the honest move is a small client component for the filter control rather than a directive at the top of the file.

The data flow, end to end

Build-time and browser data flow for the Taqari blog route, showing the 'use client' boundary BUILD TIME (server only) blog-data.js 24 posts 389 KB of source generateStaticParams returns 24 slugs one page each page.jsx server component renders body + @graph no JS to browser 24 static files .html + .rsc one per post 'use client' boundary — only serializable props cross IN THE BROWSER BlogPostClient.jsx receives: html string, toc, flat post fields owns: scroll progress only route client chunk 6.3 KB 107 KB First Load JS article body arrives as markup one post, not 24 never parsed as script What never crosses the boundary blog-data.js · the other 23 article bodies · the schema builder · the heading-anchor and h1-stripping passes

The important edge is the dashed red one. Everything above it runs once, at build time, on a machine we control. Everything below it is code a reader downloads and executes.

What we measured

Here is the relevant slice of the build output, unedited:

Route (app)                      Size  First Load JS
├ ○ /Blogs                      194 B         105 kB
├ ● /Blogs/[slug]             2.23 kB         107 kB
├ ○ /dashboard                34.4 kB         203 kB
├ ○ /interviewlab             9.11 kB         507 kB
├ ○ /leetcodeinterview          18 kB         521 kB
+ First Load JS shared by all                 101 kB

Reading the build output

Three columns, three different things, and conflating them is how people end up optimising the wrong number.

Size

JavaScript unique to that route. For /Blogs/[slug] it is 2.23 kB, and the route's own chunk on disk is 6,448 bytes before compression. That is the scroll-progress component, the breadcrumb links, and nothing else.

First Load JS

Route code plus the shared baseline. Ours is 107 kB against a 101 kB floor shared by every page, so the article route adds about 6 kB to what a visitor was going to download anyway.

The ● marker

Next.js prints ● for routes prerendered through generateStaticParams and ○ for statically rendered routes without params. Seeing ● on the blog route is the build confirming that all 24 articles became static HTML.

The body never enters the JavaScript bundle

This is checkable rather than assumed. Pick a string that exists only inside one article's body, then look for it in the compiled output:

$ grep -rqF "no score beats a wrong one" .next/static/ && echo found || echo absent
absent
$ grep -rlF "no score beats a wrong one" .next/server/app/Blogs/
.next/server/app/Blogs/llm-judge-rubric-interview-scoring.rsc
.next/server/app/Blogs/llm-judge-rubric-interview-scoring.html
.next/server/app/Blogs/nextjs-server-vs-client-components.rsc
.next/server/app/Blogs/nextjs-server-vs-client-components.html

Absent from every client chunk, present in the prerendered pages that contain it. The second pair is this article — quoting the phrase put it into our own body text, which is itself a fair demonstration of the rule: content lives in the prerendered page for the post it belongs to, and nowhere in the JavaScript. The 389 KB module that holds all 24 posts stayed on the build machine.

Moving work to the server removes JavaScript, not bytes. The article body still reaches the browser — as HTML in the prerendered page and in a roughly 44 KB RSC payload. The win is twofold: the browser never parses or executes it as script, and a reader downloads one article instead of all twenty-four. Anyone promising that server components shrink the page itself is measuring the wrong column.

What belongs in generateStaticParams

Ours is one line, and that is the correct length:

export function generateStaticParams() {
  return getAllBlogPosts().map((post) => ({ slug: post.slug }));
}

The two rules we follow

Return route params, nothing else

One object per page, keyed by the dynamic segment. It is tempting to return the post alongside the slug to save a lookup in the page component — it does not work, and the duplicated getBlogPost(slug) call costs nothing because it is an in-memory find at build time.

Know when it runs

Per the generateStaticParams documentation, it runs before the matching layouts and pages are generated, is called on navigation in development, and is not called again during revalidation. The last clause is the one that matters for a blog: a new post appears because the build ran, not because a cache expired.

The same build-time data unlocks the sitemap. src/app/sitemap.js imports the same helper and derives every URL from it, which is why publishing an article needs no second edit — the hand-maintained public/sitemap.xml is gone, and must stay gone, because a file in public/ would shadow the route.

What the boundary buys beyond bundle size

Bundle size is the benefit people quote, but it is not the only one, and on this route it is arguably not the biggest.

Structured data is assembled where the data lives

Each article page emits six cross-linked schema types in a single @graph: BlogPosting, WebPage, BreadcrumbList, Person, ImageObject and FAQPage, with Organization and WebSite referenced by @id from the root layout. Building that needs the full post object — the FAQ array, the sources, the author profile, the word count of the rendered body. A client component would have needed all of it shipped to the browser to produce markup that only a crawler reads. On the server it is free.

The word count is a good illustration. getWordCount runs over the rendered body, after the heading-anchor pass, so the number in the schema matches the page a reader sees. That ordering only works because both steps happen in the same server pass.

Server-only code stays server-only by construction

The RFC lists direct backend access as a benefit, and the useful half of that is the inverse: anything a server component closes over cannot leak into a bundle by accident. In the App Router an environment variable read in client code is simply undefined unless it carries the NEXT_PUBLIC_ prefix — and that prefix is a declaration that the value is public. Keeping data access on the server side of the boundary means the question never comes up on this route.

Rendering cost moves from every reader to one build

The regex passes that strip a stray h1, slugify headings, de-duplicate colliding ids and extract the table of contents run 24 times total, on one machine, during npm run build. Shipped as client code they would run once per reader per page view. The work is cheap either way; the asymmetry is the point.

Three mistakes this layout prevents

Marking the layout instead of the leaf

The single most expensive line you can write is 'use client' at the top of a layout. Everything below inherits it. Our root layout stays on the server and isolates the client requirement in a Providers wrapper, which is why routes like /Blogs sit at 105 kB while the pages that genuinely need an editor and a live audio stream sit at 507 kB and 521 kB. Those two numbers are not a failure; they are the cost of CodeMirror and a realtime client, paid only on the routes that use them. We wrote about keeping that editor's bundle under control separately.

Passing an object where a string would do

The props crossing into BlogPostClient are a flat set of fields, a rendered HTML string and a table of contents array. Nothing that crosses is a function, a class instance or a live Date, because props that cross the boundary have to serialize. Handing over post itself would have pulled in the data module; handing over html pulls in nothing.

Treating "it works" as "it is on the server"

A server component that accidentally becomes client code still renders correctly. Nothing breaks, the page looks fine, and the bundle quietly grows. The only reliable signal is the build output and a grep through .next/static. We run both, because correctness and boundary placement are independent properties.

When a client component is the right answer

None of this is an argument against client code. The interview surface is almost entirely client-side and should be: a code editor, a microphone, a canvas visualiser and a websocket do not have a server-rendered equivalent. The same applies to the transports behind a live voice interview and to streaming test results back into the page. Those routes carry 400 kB of extra JavaScript because they are applications, not documents.

The rule we settled on is not really about Next.js server vs client components as categories, but about which of the two a route is. A document — an article, a pricing page, a policy — should ship close to the shared baseline, because almost everything about it is knowable at build time. An application pays for its capabilities on its own routes and nowhere else. The App Router's default of server-first makes the document case automatic; the 'use client' directive is how you pay, deliberately, for the other one.

The checklist we actually use

  • Start every component on the server. Add 'use client' only when state, an effect or a browser API forces it.
  • Put the directive on the smallest component that needs it, never on a layout.
  • Render expensive data into a serializable prop on the server rather than passing the source object across.
  • Read Size and First Load JS as separate numbers, and compare the route against the shared floor, not against zero.
  • Verify, do not assume: grep the compiled client chunks for a string that should never have left the server.
  • Keep generateStaticParams limited to route params, and derive the sitemap from the same source so publishing stays a one-file change.

The blog route is a small piece of the Taqari platform, but it is the clearest demonstration we have of the App Router's trade. A 389 KB data module, 24 prerendered articles, six schema types per page, and a 6.3 KB client chunk to draw one scroll bar. The framework did not make that happen on its own — it just made the server the default, and left the boundary to us.

Frequently asked questions

What is the difference between server and client components in Next.js?

+

Server components render only on the server and ship no JavaScript to the browser. Client components are marked with the 'use client' directive, ship their code in the bundle, and can use state, effects and browser APIs like window. In the App Router everything is a server component until you opt out.

Where should I put the 'use client' directive?

+

As far down the tree as the interactivity actually lives. The directive marks an entry point, not a single file: every module a client component imports becomes client code too. Marking a top-level layout turns its whole subtree into client code, which is the most expensive mistake available.

Does moving work to the server reduce bundle size?

+

It reduces JavaScript, not bytes on the wire. Our blog route reads a 389 KB data module and ships a 6.3 KB route chunk, because the data module stays on the server. The rendered article still travels to the browser as HTML, which the browser does not have to parse and execute as script.

What should generateStaticParams return?

+

Only the dynamic route params, one object per page you want prerendered. Ours maps every published post to a slug, so the build emits one static HTML file per article. Per the Next.js docs it runs before the matching layouts and pages are generated, and is not re-run during revalidation.

Can a client component import a server component?

+

No. A client component can import other client components, but a server component passed into one has to arrive as a prop or as children. In practice this is a useful constraint: it forces you to decide what data crosses the boundary and to keep that payload serializable.

Why pass rendered HTML instead of the post object?

+

Passing the whole post object would pull the data module into the client graph and ship all 24 articles to every reader. We render one article's body on the server and hand the client component a finished HTML string, so the boundary carries exactly one page's worth of content.

Sources

Did you find this helpful?

Share this guide with your circle.

#next.js server vs client components#react server components#generatestaticparams#next.js app router#client bundle size#use client directive