DEV Community

Paradane
Paradane

Posted on

React Server Components in Production: What We Learned After Migrating a Client Dashboard

When React Server Components (RSC) shipped with Next.js 14, the promise was compelling: ship less JavaScript to the browser, render more on the server, and get better performance for free. But moving a real production dashboard from the old Pages Router to the App Router with RSC wasn't free — it was a migration that taught us a lot about what the docs don't tell you.

At Paradane, we recently migrated a client's analytics dashboard — a data-heavy application with real-time charts, filtering, and multi-tenant data isolation — from Next.js 12 (Pages Router) to Next.js 14 with the App Router and React Server Components. Here's what we learned.

The Starting Point

The dashboard had about 15 pages, each pulling data from a PostgreSQL database through tRPC procedures. Every page was a client component by default — useEffect for data fetching, client-side state for filters, and heavy chart libraries (Recharts, D3) rendering entirely in the browser. Page loads were 3-4 seconds on a good connection, and the JavaScript bundle was pushing 400KB gzipped.

What We Expected

  • Faster initial page loads because data fetching happens on the server
  • Smaller client bundles because components that don't need interactivity stay on the server
  • Better SEO for the few public-facing report pages

What Actually Happened

1. The Server/Client Boundary Is the Hardest Part

The docs make it sound simple: add "use client" at the top of files that need interactivity. In practice, the boundary is where most bugs live. A server component imports a client component that imports a server component — and suddenly you have a hydration mismatch because the server rendered something the client can't reconcile.

What worked: We adopted a strict pattern — server components fetch data and pass it as props to client components. Client components never fetch data directly. This single rule eliminated 80% of our hydration issues.

// Server Component — fetches data, renders nothing interactive
import { db } from '@/lib/db';
import { DashboardClient } from './dashboard-client';

export default async function DashboardPage({ params }: { params: { tenantId: string } }) {
  const metrics = await db.query.metrics.findMany({
    where: { tenantId: params.tenantId },
  });
  return <DashboardClient metrics={metrics} />;
}
Enter fullscreen mode Exit fullscreen mode
// Client Component — receives data, handles interactivity
'use client';

import { useState } from 'react';
import { Chart } from './chart';

export function DashboardClient({ metrics }: { metrics: Metric[] }) {
  const [filter, setFilter] = useState('7d');
  const filtered = metrics.filter(m => m.period === filter);
  return (
    <div>
      <FilterBar value={filter} onChange={setFilter} />
      <Chart data={filtered} />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

2. Chart Libraries Are Still a Problem

Recharts and D3 need the DOM. You can't render charts on the server (well, you can, but they won't be interactive). Our solution: wrap every chart in a client component with dynamic(() => import(...), { ssr: false }). This adds a loading skeleton while the chart bundle loads, but the tradeoff is worth it — no hydration mismatches, and the rest of the page renders instantly on the server.

3. Data Fetching Got Simpler (Eventually)

Moving from tRPC to direct server-side database calls was the biggest win. No more API route boilerplate, no more client-side loading states for data that could have been fetched at request time. But we had to restructure our data access layer — tRPC procedures that assumed a client context (like session cookies) needed to be rewritten as server-side functions that accept the session directly.

4. Bundle Size Dropped, But Not as Much as Expected

We went from ~400KB gzipped to ~280KB. Good, but not the 50% reduction some blog posts promised. Why? Because interactive components (charts, filters, forms) still ship their code to the client. RSC helps most with content-heavy pages — dashboards with heavy interactivity see smaller gains.

5. Streaming Is the Real Performance Win

React Suspense with streaming changed the perceived performance dramatically. Instead of waiting for all data before showing anything, we could stream the page shell immediately and fill in data as it arrived:

export default function DashboardPage({ params }: { params: { tenantId: string } }) {
  return (
    <DashboardShell>
      <Suspense fallback={<MetricsSkeleton />}>
        <MetricsSection tenantId={params.tenantId} />
      </Suspense>
      <Suspense fallback={<ChartSkeleton />}>
        <ChartsSection tenantId={params.tenantId} />
      </Suspense>
    </DashboardShell>
  );
}
Enter fullscreen mode Exit fullscreen mode

Users saw the dashboard shell in under 500ms, with data populating progressively. Time-to-interactive dropped from 3.2s to 1.1s.

The Numbers

Metric Before (Pages Router) After (App Router + RSC)
First Contentful Paint 2.1s 0.4s
Time to Interactive 3.2s 1.1s
JS Bundle (gzipped) 398KB 281KB
Lighthouse Performance 62 91
Build time 45s 68s

Build time increased — server components mean more work at build time. Not a dealbreaker, but worth knowing.

What We'd Do Differently Next Time

  1. Start with the data flow, not the component tree. Map out which data each component needs and whether it can be fetched server-side before writing any code.
  2. Use dynamic(() => import(...), { ssr: false }) for ALL third-party interactive libraries. Don't try to make them work with SSR — it's not worth the debugging time.
  3. Embrace streaming early. Suspense boundaries aren't an afterthought — they're the architecture. Plan them before you write components.
  4. Keep the server/client boundary rule simple. Server components fetch and pass data. Client components receive data and handle interactivity. No exceptions until you have a very good reason.

The Bottom Line

React Server Components are a real improvement, not just hype. The performance gains are measurable, and the programming model — once you internalize the server/client boundary — is cleaner than the old useEffect-for-everything pattern. But the migration isn't free, and the learning curve is steeper than the docs suggest.

If you're considering the move, start with a content-heavy page (blog, docs, marketing pages) where RSC shines brightest. Save the interactive dashboard migration for when you've built some intuition about the boundary.


At Paradane, we build and migrate web applications for businesses — from e-commerce platforms to SaaS dashboards. If you're planning a Next.js migration or building a new React application, we'd be happy to share what we've learned.

Top comments (0)