DEV Community

Cover image for # Learning Patterns #40 — Next.js Client-Side Rendering (CSR): bundles, performance, and how to make it fast
Md Enayetur Rahman
Md Enayetur Rahman

Posted on

# Learning Patterns #40 — Next.js Client-Side Rendering (CSR): bundles, performance, and how to make it fast

If you’ve spent most of your time in React, CSR is the “default mental model”:

Ship JavaScript → run React in the browser → fetch data → render UI.

Next.js supports that model—but also gives you alternatives (SSR/SSG/Server Components). That means CSR becomes a deliberate choice, not an automatic one.

This post is a practical, Next.js-focused deep dive into CSR:

  • What CSR looks like in modern Next.js (App Router)
  • What “JS bundle” really means and why it hurts performance
  • How to measure CSR costs
  • CSR pros/cons (when it’s the right pattern)
  • Concrete ways to improve CSR performance (with code)

1) What CSR means in Next.js

In Next.js, CSR means:

  • The initial HTML is minimal (often a shell)
  • React runs in the browser
  • Data fetching and most rendering happens on the client

In the App Router, CSR is most explicit via Client Components, marked by the directive:

"use client";
Enter fullscreen mode Exit fullscreen mode

CSR example (Client Component page)

// app/products/page.tsx
"use client";

import { useEffect, useState } from "react";

type Product = { id: string; title: "string; price: number };"

export default function ProductsPage() {
  const [products, setProducts] = useState<Product[]>([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    let cancelled = false;

    async function load() {
      setLoading(true);
      const res = await fetch("/api/products");
      const data = (await res.json()) as Product[];
      if (!cancelled) {
        setProducts(data);
        setLoading(false);
      }
    }

    load();
    return () => { cancelled = true; };
  }, []);

  if (loading) return <p>Loading products…</p>;

  return (
    <main>
      <h1>Products</h1>
      <ul>
        {products.map((p) => (
          <li key={p.id}>
            {p.title} — ${p.price}
          </li>
        ))}
      </ul>
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

This is “pure CSR”:

  • No product HTML is available until the browser runs JS, fetches data, then renders.
  • Great for interactivity, potentially rough for first-load performance.

2) The JS bundle: what it is, and why it matters

What “bundle” means

When you build a Next.js app, your code becomes JavaScript bundles (files) that the browser downloads.

For CSR pages/components, the browser must:

  1. Download the JS
  2. Parse it
  3. Execute it
  4. Hydrate React
  5. Fetch data
  6. Render meaningful UI

Even if your network is fast, parse + execute time can be expensive—especially on mid-range Android devices.

A concrete “bundle pain” scenario

Imagine adding:

  • a chart library
  • a rich text editor
  • a date-picker
  • a UI component library + icons …to a CSR page.

That page might jump from “small bundle” to “big bundle”, and you’ll feel it as:

  • slower Time to Interactive (TTI)
  • delayed Largest Contentful Paint (LCP)
  • more main-thread blocking

3) How to check CSR performance (practical checklist)

A) Check build output (quick signal)

Run:

npm run build
npm run start
Enter fullscreen mode Exit fullscreen mode

In the build output, Next.js prints route sizes and shared JS chunks. Watch for:

  • routes with unexpectedly large client JS
  • large shared chunks that every page downloads

B) Inspect bundle composition (what’s inside?)

Use bundle analyzer.

Install:

npm i -D @next/bundle-analyzer
Enter fullscreen mode Exit fullscreen mode

next.config.js:

const withBundleAnalyzer = require("@next/bundle-analyzer")({
  enabled: process.env.ANALYZE === "true",
});

module.exports = withBundleAnalyzer({
  // your config
});
Enter fullscreen mode Exit fullscreen mode

Run:

ANALYZE=true npm run build
Enter fullscreen mode Exit fullscreen mode

You’ll get an interactive treemap showing which libraries dominate your client bundle.

C) Use Chrome DevTools: Network + Performance

In DevTools → Network:

  • Filter by JS
  • Look at:
    • total JS transferred
    • “Size” vs “Transferred” (compression helps, but parse cost still exists)

In DevTools → Performance:

  • record a reload
  • watch for:
    • long scripting tasks
    • hydration costs
    • layout thrashing after data loads

D) Lighthouse / PageSpeed

Run Lighthouse in Chrome:

  • pay attention to:
    • LCP
    • TBT (Total Blocking Time)
    • INP (Interaction to Next Paint)

CSR-heavy pages often struggle with TBT/INP if bundles are large or state updates are frequent.


4) CSR: pros and cons (when it’s the right pattern)

Pros

  • Great interactivity: complex UI states, animations, drag/drop, real-time updates
  • Client-side caching is easy (SWR/React Query)
  • Works well behind auth (dashboards) where SEO doesn’t matter
  • Offloads server work for some workloads

Cons

  • Slower first meaningful paint if you ship big JS + fetch data on mount
  • SEO tradeoffs (bots may not execute JS reliably depending on setup)
  • More performance variance across devices
  • Harder to keep bundles small as features grow

Rule of thumb:

  • Public marketing pages: avoid pure CSR as the default.
  • Authenticated app experiences: CSR can be perfect—if you keep JS lean.

5) Improving CSR performance (with code)

Here are upgrades that keep the “CSR pattern” but reduce its cost.

5.1 Split heavy UI with dynamic import

If a page is CSR but only part needs a heavy library, load it only when needed.

"use client";

import dynamic from "next/dynamic";

const HeavyChart = dynamic(() => import("./HeavyChart"), {
  ssr: false,
  loading: () => <p>Loading chart…</p>,
});

export default function Dashboard() {
  return (
    <main>
      <h1>Analytics</h1>
      <HeavyChart />
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

Why it helps:

  • keeps initial JS smaller
  • moves cost to a later moment (when user actually needs it)

5.2 Reduce re-renders + main-thread work

If you compute filters/sorts on every keystroke, your UI can stutter.

"use client";

import { useDeferredValue, useMemo, useState } from "react";

export default function ProductSearch({ products }) {
  const [query, setQuery] = useState("");
  const deferredQuery = useDeferredValue(query);

  const filtered = useMemo(() => {
    const q = deferredQuery.trim().toLowerCase();
    if (!q) return products;
    return products.filter((p) => p.title.toLowerCase().includes(q));
  }, [deferredQuery, products]);

  return (
    <>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search…"
      />
      <p>{filtered.length} results</p>
      {/* render list */}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Why it helps:

  • keeps typing responsive by deferring expensive filtering

5.3 Virtualize large lists

Rendering 1,000 items in a CSR page is expensive. Virtualize.

Example with react-window:

npm i react-window
Enter fullscreen mode Exit fullscreen mode
"use client";

import { FixedSizeList as List } from "react-window";

export default function ProductList({ products }) {
  return (
    <List height={600} itemCount={products.length} itemSize={56} width={"100%"}>
      {({ index, style }) => (
        <div style={style}>
          {products[index].title} — ${products[index].price}
        </div>
      )}
    </List>
  );
}
Enter fullscreen mode Exit fullscreen mode

Why it helps:

  • renders only what’s visible
  • massively reduces DOM + React work

5.4 Cache client fetching (avoid refetch storms)

CSR pages that refetch on every navigation feel slow. Use SWR:

npm i swr
Enter fullscreen mode Exit fullscreen mode
"use client";

import useSWR from "swr";

const fetcher = (url: string) => fetch(url).then((r) => r.json());

export default function Products() {
  const { data, isLoading } = useSWR("/api/products", fetcher, {
    revalidateOnFocus: false,
    dedupingInterval: 30_000,
  });

  if (isLoading) return <p>Loading…</p>;

  return (
    <ul>
      {data.map((p) => (
        <li key={p.id}>{p.title}</li>
      ))}
    </ul>
  );
}
Enter fullscreen mode Exit fullscreen mode

Why it helps:

  • makes repeated visits feel instant
  • reduces network churn

5.5 Load third-party scripts intentionally

If you must use third-party scripts, don’t block the main thread during initial load.

import Script from "next/script";

export default function Page() {
  return (
    <>
      <Script
        src="https://example.com/widget.js"
        strategy="lazyOnload"
      />
      <main></main>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Why it helps:

  • avoids delaying interactivity for non-critical scripts

6) A practical CSR decision framework

Use CSR when:

  • the page is behind auth
  • interactivity is high
  • SEO is irrelevant
  • initial content can be “good enough” as a skeleton

Avoid pure CSR when:

  • it’s a landing page / marketing page
  • first impression matters (speed, clarity)
  • content should be indexable/searchable
  • the UI is mostly static

And if you must stay CSR, your biggest wins usually come from:
1) smaller bundles (split heavy stuff)

2) less main-thread work (memo/virtualize/defer)

3) smarter fetching (cache + avoid refetch churn)


Conclusion

CSR in Next.js isn’t “wrong”—it’s a pattern with clear tradeoffs.

If you choose CSR, treat performance as a product feature:

  • measure bundle size
  • profile hydration and scripting
  • split heavy components
  • keep lists and state updates cheap
  • cache aggressively

The payoff is worth it: you can build app-like, highly interactive experiences—without making users wait for your JavaScript to wake up.

Top comments (0)