DEV Community

Servet Arslan
Servet Arslan

Posted on

How We Built a 40-Page Real-Time Dashboard with Next.js 16 and React Server Components

The Problem

We needed a dashboard for our webhook delivery platform. Not a simple admin panel — a full-blown monitoring dashboard with real-time analytics, endpoint management, team collaboration, and 40+ distinct pages.

The requirements were brutal:

  • Real-time delivery stats updating every second
  • SSE streaming for live event feeds
  • Complex charts with 24h/7d/30d time ranges
  • Role-based access (admin, editor, viewer)
  • Dark mode (because obviously)
  • Mobile responsive (because people check dashboards on their phones at 2am)

We chose Next.js 16. Here's why, and what we learned building it.

Why Next.js 16?

We evaluated a few options:

Plain React + Vite — Fast, simple, but no SSR. We needed SEO for our docs and landing pages, and server-side rendering for the dashboard's initial load.

Next.js 14/15 — Good, but we wanted the latest React Server Components patterns. Next.js 16 has better streaming, better caching, and the App Router is more mature.

Remix — Interesting, but the ecosystem is smaller. We needed a UI component library with strong Next.js support, and shadcn/ui fits perfectly with Next.js.

Next.js 16 won because of React Server Components. Here's why that matters for a dashboard.

React Server Components: The Real Advantage

The biggest misconception about RSC is that it's about performance. It's not — at least not primarily. The real advantage is data fetching architecture.

In a traditional React dashboard, you'd have something like:

// Client component - fetches on every render
function DeliveryStats() {
  const [stats, setStats] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch('/api/stats')
      .then(res => res.json())
      .then(data => {
        setStats(data);
        setLoading(false);
      });
  }, []);

  if (loading) return <Skeleton />;
  return <StatsChart data={stats} />;
}
Enter fullscreen mode Exit fullscreen mode

This creates a waterfall: component renders → loading state → fetch → re-render. Every page has this pattern, and it gets messy fast.

With RSC in Next.js 16:

// Server component - fetches before render
async function DeliveryStats() {
  const stats = await getDeliveryStats();
  return <StatsChart data={stats} />;
}
Enter fullscreen mode Exit fullscreen mode

No loading state. No useEffect. No waterfall. The data is ready before the component renders. For a dashboard with 15 different data panels on one page, this eliminates a massive amount of boilerplate.

But here's the trick — you don't make everything a server component. The pattern we landed on:

  • Server components for data fetching and layout
  • Client components for interactive elements (charts, forms, real-time updates)
// app/dashboard/page.tsx (Server Component)
export default async function DashboardPage() {
  const [endpoints, recentDeliveries, alerts] = await Promise.all([
    getEndpoints(),
    getRecentDeliveries(),
    getActiveAlerts(),
  ]);

  return (
    <DashboardLayout>
      <StatsOverview data={endpoints} />
      <RecentDeliveriesStream initialData={recentDeliveries} />
      <AlertPanel alerts={alerts} />
    </DashboardLayout>
  );
}
Enter fullscreen mode Exit fullscreen mode
// components/RecentDeliveriesStream.tsx (Client Component)
'use client';

export function RecentDeliveriesStream({ initialData }) {
  const [deliveries, setDeliveries] = useState(initialData);

  useEffect(() => {
    const eventSource = new EventSource('/v1/stream/deliveries');
    eventSource.onmessage = (event) => {
      const newDelivery = JSON.parse(event.data);
      setDeliveries(prev => [newDelivery, ...prev].slice(0, 50));
    };
    return () => eventSource.close();
  }, []);

  return (
    <div>
      {deliveries.map(d => <DeliveryRow key={d.id} delivery={d} />)}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The server component fetches the initial data. The client component takes it from there with SSE. Best of both worlds.

Real-Time SSE Integration

This was the hardest part. SSE (Server-Sent Events) with Next.js is tricky because Next.js wants to cache everything, and SSE is the opposite of cacheable.

Our approach:

1. Separate SSE endpoint

We don't serve SSE through Next.js. Our Rust API handles SSE directly:

// The SSE connection goes directly to our API
const API_URL = process.env.NEXT_PUBLIC_API_URL;

function useDeliveryStream() {
  useEffect(() => {
    const eventSource = new EventSource(
      `${API_URL}/v1/stream/deliveries`,
      { withCredentials: true }
    );

    eventSource.addEventListener('delivery', (event) => {
      const delivery = JSON.parse(event.data);
      // Update state
    });

    return () => eventSource.close();
  }, []);
}
Enter fullscreen mode Exit fullscreen mode

2. Optimistic updates

When a user replays a webhook, we don't wait for the SSE event. We update the UI immediately and reconcile when the event arrives:

async function handleReplay(deliveryId: string) {
  // Optimistic update
  setDeliveries(prev =>
    prev.map(d =>
      d.id === deliveryId
        ? { ...d, status: 'replaying' }
        : d
    )
  );

  // Actual API call
  await replayDelivery(deliveryId);

  // SSE will update the status when it completes
}
Enter fullscreen mode Exit fullscreen mode

3. Connection status indicator

SSE connections drop. We built a reconnecting EventSource wrapper with exponential backoff and a visual indicator:

class ReconnectingEventSource {
  private eventSource: EventSource | null = null;
  private retryCount = 0;
  private maxRetryDelay = 30000;

  connect(url: string) {
    this.eventSource = new EventSource(url);

    this.eventSource.onerror = () => {
      this.eventSource?.close();
      const delay = Math.min(
        1000 * Math.pow(2, this.retryCount),
        this.maxRetryDelay
      );
      this.retryCount++;
      setTimeout(() => this.connect(url), delay);
    };

    this.eventSource.onopen = () => {
      this.retryCount = 0;
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

The 40-Page Architecture

40 pages sounds like a lot, but most of them share patterns. Here's how we organized it:

app/
├── (auth)/
│   ├── login/
│   ├── register/
│   └── forgot-password/
├── (dashboard)/
│   ├── layout.tsx          # Shared dashboard layout
│   ├── page.tsx            # Overview (home)
│   ├── endpoints/
│   │   ├── page.tsx        # List
│   │   ├── [id]/
│   │   │   ├── page.tsx    # Detail
│   │   │   ├── edit/       # Edit form
│   │   │   └── deliveries/ # Delivery history
│   │   └── new/            # Create
│   ├── deliveries/
│   │   ├── page.tsx        # All deliveries
│   │   └── [id]/           # Delivery detail
│   ├── analytics/
│   │   ├── page.tsx        # Overview charts
│   │   ├── trends/         # Trend analysis
│   │   └── endpoints/      # Per-endpoint stats
│   ├── alerts/
│   │   ├── page.tsx        # Alert rules
│   │   └── [id]/           # Alert detail
│   ├── team/
│   │   ├── page.tsx        # Members
│   │   ├── invitations/    # Pending invites
│   │   └── settings/       # Team settings
│   └── settings/
│       ├── page.tsx        # General
│       ├── security/       # API keys, 2FA
│       ├── billing/        # Plans, invoices
│       └── notifications/  # Alert preferences
└── api/
    └── ...                 # API routes
Enter fullscreen mode Exit fullscreen mode

The key insight: the layout does the heavy lifting. The (dashboard)/layout.tsx handles sidebar, navigation, breadcrumbs, and user context. Individual pages just focus on their content.

// app/(dashboard)/layout.tsx
export default async function DashboardLayout({ children }) {
  const user = await getCurrentUser();
  const team = await getTeam(user.teamId);

  return (
    <div className="flex h-screen">
      <Sidebar team={team} user={user} />
      <main className="flex-1 overflow-auto">
        <Breadcrumbs />
        {children}
      </main>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Authentication Flow

We use NextAuth v5 with JWT tokens. The tricky part was handling token refresh in server components.

// middleware.ts
export async function middleware(request: NextRequest) {
  const token = await getToken({ req: request });

  if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  // Check token expiry
  if (token && token.exp && Date.now() >= token.exp * 1000) {
    const refreshed = await refreshAccessToken(token);
    if (!refreshed) {
      return NextResponse.redirect(new URL('/login', request.url));
    }
  }

  return NextResponse.next();
}
Enter fullscreen mode Exit fullscreen mode

For server components, we use getToken() directly:

// Any server component
import { getToken } from 'next-auth/jwt';

export default async function ProtectedPage() {
  const token = await getToken({ req: headers() });
  if (!token) redirect('/login');

  const data = await getProtectedData(token.sub);
  return <DataView data={data} />;
}
Enter fullscreen mode Exit fullscreen mode

Lessons Learned

1. Don't fight the framework. Next.js wants you to fetch data in server components. Let it. Trying to make everything a client component defeats the purpose.

2. Streaming is your friend. Use <Suspense> boundaries aggressively. Load the shell first, stream in the heavy data panels:

export default function DashboardPage() {
  return (
    <div>
      <StatsBar />
      <Suspense fallback={<ChartSkeleton />}>
        <DeliveryChart />
      </Suspense>
      <Suspense fallback={<TableSkeleton />}>
        <RecentDeliveries />
      </Suspense>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

3. shadcn/ui + Tailwind = fast iteration. We built 40 pages in 3 weeks. The component library did the heavy lifting for consistent UI.

4. Server actions simplify mutations. No need for API routes for simple form submissions:

async function createEndpoint(formData: FormData) {
  'use server';
  const name = formData.get('name') as string;
  const url = formData.get('url') as string;
  await db.endpoints.create({ data: { name, url } });
  revalidatePath('/endpoints');
}
Enter fullscreen mode Exit fullscreen mode

5. Bundle size matters. We use dynamic imports for heavy components (charts, code editors) to keep the initial load fast:

const DeliveryChart = dynamic(() => import('./DeliveryChart'), {
  loading: () => <ChartSkeleton />,
  ssr: false,
});
Enter fullscreen mode Exit fullscreen mode

The Stack

  • Next.js 16 (App Router, RSC)
  • TypeScript (strict mode)
  • Tailwind CSS + shadcn/ui
  • Recharts for charts
  • NextAuth v5 for auth
  • SSE for real-time updates
  • Vercel for deployment

How do you handle real-time updates in your Next.js dashboards? I'm curious if anyone has a better SSE pattern.

Top comments (0)