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} />;
}
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} />;
}
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>
);
}
// 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>
);
}
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();
}, []);
}
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
}
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;
};
}
}
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
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>
);
}
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();
}
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} />;
}
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>
);
}
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');
}
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,
});
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.