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.
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
| Library | Renders to | Can it render on the server? |
|---|---|---|
| Chart.js | <canvas> | No — needs a real canvas element |
| ApexCharts | SVG, via DOM APIs | No — constructs nodes imperatively |
| Highcharts | SVG, via DOM APIs | No — the docs warn against calling it during server data fetching |
| Recharts | SVG, React components | Partly — with fixed width/height; ResponsiveContainer measures the DOM |
Hand-written <svg> | SVG | Yes — 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-js — 68 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.
| Choose | When |
|---|---|
Server-rendered <svg> | Sparklines, bars, donuts, anything with a fixed dataset and no interaction |
| A client charting library | Tooltips 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
| Mistake | Symptom | Fix |
|---|---|---|
| Chart component imports a large data module for labels | Tens of KB of unrelated data in the route's first load | Pass labels as props; import type-only from big modules |
"use client" on the page instead of the chart | The whole page tree hydrates | Push the directive down to the chart leaf |
| Importing the chart library at module scope on a page that rarely shows it | Cost paid on every visit | next/dynamic with ssr: false for the chart |
No role/aria-label on an SVG chart | Screen readers announce nothing, or announce raw path data | role="img" plus a label; put the numbers in a table nearby |
| Fixed colours in a themed dashboard | The chart stays light when the UI goes dark | currentColor, or CSS variables from your token scale |
| A canvas chart rendered during server data fetching | document is not defined at build or request time | Canvas renderers are client-only by construction |
| Fallback of a different height than the chart | Layout shift when the chart mounts | Reserve 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.
