Skip to main content
ASoc
Tutorial

Next.js Charts: The Bill Isn't the Library, It's the Boundary

Every chart library is a client component. What that actually costs, measured here: 42 KB gzipped across twelve routes, from a five-line constant nobody suspected.

The ASoc Team8 min read

Every charting library for Next.js lands in the same place: a Client Component. Canvas renderers need a browser to draw into, and even SVG renderers measure the DOM to size themselves. That is fine — but the bill is not the library's own weight. It is everything else the component's module graph drags across the boundary with it, and on this codebase that cost was measured at 42 KB gzipped on twelve routes from a five-line constant.

Why the boundary is not optional

LibraryRenders toCan it render on the server?
Chart.js<canvas>No — needs a real canvas element
ApexChartsSVG, via DOM APIsNo — constructs nodes imperatively
HighchartsSVG, via DOM APIsNo — the docs warn against calling it during server data fetching
RechartsSVG, React componentsPartly — with fixed width/height; ResponsiveContainer measures the DOM
Hand-written <svg>SVGYes — it is just markup

So a dashboard chart is a "use client" component in all but the last row. That is not a criticism of the libraries; drawing an interactive chart requires the interactive part.

What people underestimate is what "use client" does to imports. The directive marks a boundary, and every module that component imports — transitively — enters the client graph, whether or not the browser needs it.

The 42 KB a five-line constant cost us

This is the finding from running a bundle analyzer on this codebase, and it is the clearest illustration of the boundary tax we have.

One chunk measured 207,323 B raw / 42,197 B gzipped, and sat on the first-load path of twelve routes — including /dashboard, a signed-in page. Its contents turned out to be the product catalogue: 1,103 occurrences of the string asoc-.

Nothing on those pages rendered the catalogue. The cause was that a client component imported a small named constant — five string pairs — from src/data/catalog.ts, an 8,000-line module. The type half of the import cost nothing, because types are erased. The runtime value pulled the entire module into the client graph of every component that reached it: DownloadMenu, EditionPicker, ProductDownloadGroup, TemplateCard, and every grid built on them.

Apply that to charts and the shape is identical. A <RevenueChart> marked "use client" that imports your pricing constants, your date helpers, or your product registry to label its axes will ship all of them. The chart library is the part you budgeted for; the import you did not think about is the part that hurts.

The second measurement here says the same thing from another direction: @supabase/ssr plus auth-js68 KiB gzipped, 255 KiB parsed — reached /, /blog, /docs and /pricing because two site-wide client components imported the auth client at module scope. The fix in src/lib/supabase/lazyClient.ts was not a smaller library; it was moving the import inside the effect that needed it.

The rule that falls out

Keep the client component a leaf, and pass it data as props. A chart should import the charting library and nothing else from your codebase.

// app/dashboard/page.tsx — a Server Component
import { getMonthlyRevenue } from "@/lib/reports";
import RevenueChart from "@/components/charts/RevenueChart";

export default async function Page() {
  const points = await getMonthlyRevenue(); // runs on the server
  return <RevenueChart points={points} />;  // plain array crosses the wire
}
// components/charts/RevenueChart.tsx
"use client";
import { LineChart, Line, XAxis, YAxis, Tooltip } from "recharts";

export default function RevenueChart({
  points,
}: {
  points: { month: string; total: number }[];
}) {
  return (
    <LineChart width={640} height={280} data={points}>
      <XAxis dataKey="month" />
      <YAxis />
      <Tooltip />
      <Line dataKey="total" dot={false} />
    </LineChart>
  );
}

The query, the aggregation and the formatting logic stay on the server. What crosses is an array of objects. This codebase's own client/server split follows the same discipline — 24 of its 92 components are Client Components, and not one of them is an atom.

When you do not need a library at all

A chart with no interaction is markup. Rendering it in a Server Component ships zero bytes of JavaScript, because there is no component to hydrate:

// A Server Component — no "use client", no dependency.
export default function Sparkline({ values }: { values: number[] }) {
  const max = Math.max(...values, 1);
  const step = 100 / Math.max(values.length - 1, 1);
  const d = values
    .map((v, i) => `${i === 0 ? "M" : "L"} ${i * step} ${30 - (v / max) * 30}`)
    .join(" ");
  return (
    <svg viewBox="0 0 100 30" role="img" aria-label={`Trend, ${values.length} points`}>
      <path d={d} fill="none" stroke="currentColor" strokeWidth="1.5" />
    </svg>
  );
}

To be straight about provenance: that component is written for this article, not lifted from this repo — this storefront has no charts, because a template marketplace has nothing to plot. The measurements above are ours; this snippet is an illustration of where they lead.

Two details in it are not incidental. role="img" with an aria-label is what stops a chart being an unlabelled graphic to a screen reader — the single most common accessibility defect in dashboard UIs, and one no automated scanner will report as a chart problem. And stroke="currentColor" inherits the text colour, which is what makes an SVG chart survive a dark-mode toggle without a second palette.

ChooseWhen
Server-rendered <svg>Sparklines, bars, donuts, anything with a fixed dataset and no interaction
A client charting libraryTooltips on hover, zoom, brushing, live-updating series, 6+ chart types

For a dashboard with eleven chart types, hand-rolling is the wrong economy. For the three sparklines in a stat row, importing a library is the wrong economy.

Mistakes and how they show up

MistakeSymptomFix
Chart component imports a large data module for labelsTens of KB of unrelated data in the route's first loadPass labels as props; import type-only from big modules
"use client" on the page instead of the chartThe whole page tree hydratesPush the directive down to the chart leaf
Importing the chart library at module scope on a page that rarely shows itCost paid on every visitnext/dynamic with ssr: false for the chart
No role/aria-label on an SVG chartScreen readers announce nothing, or announce raw path datarole="img" plus a label; put the numbers in a table nearby
Fixed colours in a themed dashboardThe chart stays light when the UI goes darkcurrentColor, or CSS variables from your token scale
A canvas chart rendered during server data fetchingdocument is not defined at build or request timeCanvas renderers are client-only by construction
Fallback of a different height than the chartLayout shift when the chart mountsReserve the chart's box; this site measures CLS 0 on every page

Frequently asked questions

What is the best charting library for Next.js? For an App Router project, the one that is a set of React components rather than an imperative drawing API — Recharts is the common pick for that reason, because it composes as JSX and can render server-side at fixed dimensions. Chart.js is smaller and faster for large series but is canvas-only, so it is always client-side.

Can I render charts in a Server Component? Yes, if you write the SVG yourself. No, if you use a library that touches document or canvas. The dividing line is not the framework — it is whether producing the picture requires a browser.

Why is my chart making the bundle so much bigger than the library? Almost always a transitive import. The "use client" boundary pulls every module the component imports into the browser graph, so one constant imported from a large data file brings the whole file. Run an analyzer and read what is actually in the chunk — that is how the 42 KB above was found.

Does next/dynamic fix the boundary cost? It moves it. The chart's code leaves the initial bundle and loads on demand, which is the right call for below-the-fold or tab-hidden charts. It does not shrink what the chart imports, so a chart that drags a data module still drags it — just later.

Templates in this post

ASoc Crest Admin is a classic sidebar admin with five dashboards (Ecommerce, Analytics, CRM, Crypto, Projects) and eight app modules; ASoc Estate Admin is a real-estate management admin with three dashboards built around property and agent operations; ASoc Lura is the multi-vertical suite — eleven dashboards across roughly 177 routes, in React, Next.js, Vue and Angular editions. All three ship their chart components as client leaves fed by server-side data.

Browse the full sets: React admin templates, Next.js admin templates, Tailwind admin templates. For how the 42 KB finding was actually made, read the Next.js bundle analyzer post; for where the client boundary belongs in general, read Server Components vs Client Components.

Keep reading

Tutorial7 min read

A Next.js CI Pipeline in 23 Lines, Timed Step by Step

Real per-step timing from a production run — 100 seconds total, cheapest checks first, no separate typecheck step because next build already does one.

Read more
Tutorial11 min read

A Next.js Contact Form with Server Actions, Zod and Resend

No API route, no client fetch, and it still submits with JavaScript off. Validation, a honeypot, a rate limit that survives serverless, and the from-address trap that kills deliverability.

Read more
Tutorial10 min read

A Next.js Content Security Policy That Keeps Static Rendering

The documented nonce recipe turns every route it touches dynamic. The static-safe policy we ship instead, what 'unsafe-inline' really costs, and what the header still blocks.

Read more