back / blog
8 min read

Building a real-time SaaS dashboard with Next.js 16 and Recharts — what I'd do differently

A candid review of building a real-time dashboard using the Next.js App Router and Recharts, covering state sync, virtualisation, and light-flash fixes.

I built a real-time analytics panel a few months back that needed to ingest live transaction streams, aggregate metrics on the fly, and render them without making the browser lag. The project was Pulse Dashboard, an internal monitoring interface that sits on top of a heavy-traffic transaction ledger. Looking back at the codebase now, there are three distinct architectural choices I regret, two that saved my sanity, and one library I will probably never use again for real-time data.

When you are pulling in updates every few hundred milliseconds, standard React rendering habits fall apart. Next.js 16 introduces some great caching defaults, but combining server-driven architectures with client-heavy chart manipulation requires a level of friction that nobody tells you about in the marketing materials.

The App Router layout trap

Next.js 16 forces you to think in Server Components by default. For static pages or content-heavy sites, it is brilliant. For an interactive, state-heavy dashboard, it becomes a game of layout boundaries.

Initially, I tried to keep the core metric aggregations on the server, fetching the initial historical baseline via an async Server Component and passing that down to the client chart components. The theory was great: minimal JavaScript to hydrate the initial page paint.

// The initial approach that caused layout hydration mismatches
export default async function DashboardLayout() {
  const baselineData = await fetchDashboardMetrics();

  return (
    <div className="dashboard-grid">
      <MetricSummary baseline={baselineData} />
      <RealTimeChart initialData={baselineData.series} />
    </div>
  );
}

The breakdown occurred the moment the live WebSocket stream kicked in on the client. The client-side state machine had to immediately reconcile the data structure fetched on the server with high-frequency deltas. Because the server payload was timestamped to the millisecond of the execution run in the data centre, the client-side hydration frequently threw mismatches if the connection handshake took longer than 40ms.

I wasted three days trying to shim the hydration differences before admitting defeat. For high-frequency dashboards, the Server Component should only render the empty shell, shell skeletons, and layout wrappers. The actual data pipeline belongs entirely on the client from layout mount onward. Moving the data fetching to a clean useEffect or an isolated client-side provider stopped the layout shift and removed the complex hydration reconciliation logic.

Recharts under high-frequency load

For the visualisation layer, I picked Recharts. It is the default choice for half the web, it has a gentle learning curve, and it looks clean out of the box. It is also an absolute CPU hog when you feed it live updates.

Recharts is built on top of SVG elements. Every time a new data point arrives over the wire, the entire SVG path needs to be recalculated and re-rendered by the browser engine. When you are plotting four separate line series across 100 data points, updating every 200ms, your main thread starts choking. I watched the Chrome DevTools performance profiler show rendering frames spiking up to 45ms.

I managed to stabilise it on the Pulse Dashboard project by aggressively throttling the incoming data array down to a maximum of 50 intervals and implementing a custom wrapper that skipped rendering if the delta was negligible.

// Throttling updates to save the main thread from SVG thrashing
import React, { useMemo } from 'react';
import { ResponsiveContainer, LineChart, Line, XAxis } from 'recharts';

export function ActiveStreamsChart({ rawTicks }: { rawTicks: Tick[] }) {
  const optimisedData = useMemo(() => {
    if (rawTicks.length < 50) return rawTicks;
    return rawTicks.slice(-50);
  }, [rawTicks]);

  return (
    <ResponsiveContainer width="100%" height={300}>
      <LineChart data={optimisedData}>
        <XAxis dataKey="timestamp" tickLine={false} />
        <Line type="monotone" dataKey="value" stroke="#0070f3" dot={false} isAnimationActive={false} />
      </LineChart>
    </ResponsiveContainer>
  );
}

The absolute life-saver here was setting isAnimationActive={false} on the <Line/> component. If you leave the default entry animations turned on, Recharts will try to animate the transition of every single new data point. It looks pretty for a static slide deck, but in a real-time dashboard, it causes an infinite animation loop that burns through laptop batteries.

If I had to build this again tomorrow, I would skip Recharts entirely and use something canvas-based like uPlot or a low-level wrapper around Visx. SVGs are fine for static analytics, but they do not scale for high-frequency live lines.

The customer table and layout flashing

Below the main chart layout, the dashboard displays an audit trail of incoming raw transactions. On a busy morning, this log ticks up by thousands of items per hour. The initial implementation used standard React mapping over a local state array. Within twenty minutes of live use, scrolling the page felt like dragging a concrete block across wet sand.

The fix was introducing virtual scrolling. I pulled in @tanstack/react-virtual to ensure that only the 15 rows currently visible in the viewport were actually rendered into the DOM.

const parentRef = React.useRef<HTMLDivElement>(null);

const rowVirtualiser = useWindowVirtualizer({
  count: transactions.length,
  estimateSize: () => 52,
  overscan: 5,
});

Using the window virtualiser kept the DOM light, drop-downs remained responsive, and memory usage stayed flat.

Another massive irritation was the dark mode flash. The app uses an explicit dark theme, matching the branding of Noir Studio. Because Next.js serves the initial HTML shell, if your theme provider relies on local storage check inside a client-side hook, the browser will render the white background shell for a brief 100ms window before the client script executes and flips the CSS classes.

To fix it properly without layout flashing, you have to inject a blocking script directly into the html tag inside layout.tsx to read the cookie or system preference before the browser even attempts the initial paint.

Trade-offs and what didn't work

The biggest mistake on this project was over-engineering the state management sync. I built a multi-layered local caching system using a mix of context providers and custom hooks to make sure the data shared between the customer transaction table and the line charts stayed perfectly uniform. It introduced immense complexity, made debugging a nightmare, and added a layer of latency that counteracted the optimisation efforts.

I also tried to use React Server Actions to handle some of the low-frequency background calculations, like generating hourly csv exports on demand. The Server Action mechanism works fine for simple form submissions, but passing large analytical datasets back and forth over the wire via hidden POST requests introduced noticeable UI freezes. I eventually stripped those out and replaced them with standard, isolated API routes running on specialised Edge runtime configurations.

Closing take

Next.js 16 is an excellent tool for building enterprise SaaS architectures, but you should not force its server-first patterns onto highly dynamic real-time layers. Keep your server components strict, lean, and static, push the live streaming pipelines completely into isolated client boundaries, and turn off your chart animations before the browser gives up entirely. If you want to see how this all balances out visually when integrated with clean interface design, take a look at the final implementation on the Pulse Dashboard project page.