By the end of this guide, you will have a working real-time analytics dashboard built with Next.js 15 and Recharts 2.x, pulling live data via Server-Sent Events and rendering auto-updating line, bar, and metric cards. Plan for around 90 minutes from a blank repo to a deployable prototype.

What You'll Build

  • A Next.js 15 App Router page that renders a live analytics dashboard
  • A Server-Sent Events endpoint that streams fresh data every five seconds
  • A Recharts line chart and bar chart that update without a full page reload
  • Responsive metric cards showing key numbers like active users, revenue, and conversion rate
  • A clean layout ready to swap in your own data source

Prerequisites

  • Node.js 20+ and pnpm 9 installed
  • Basic familiarity with React and TypeScript
  • A Next.js 15 project (App Router) — new or existing
  • No prior Recharts experience needed

Step 1: Install Recharts and Set Up the Project

Recharts is a composable chart library built on top of React and D3. It handles SVG rendering, tooltips, and responsiveness for you. As of mid-2026, Recharts 2.12 is the stable release and works well with React 19.

In your project root, run:

pnpm add recharts
pnpm add -D @types/recharts

Create two folders you will use throughout this guide:

mkdir -p app/dashboard/components app/api/analytics-stream

Common pitfall: Recharts requires react and react-dom 18 or 19. If you are on an older version, the charts will render but hooks may behave unexpectedly. Run pnpm list react to confirm your version before continuing.

Step 2: Build the Server-Sent Events Endpoint

Why use Server-Sent Events instead of WebSockets?

Server-Sent Events (SSE) are simpler for one-directional data flow. The server pushes updates; the client only listens. WebSockets make sense when the client also sends frequent messages. For a dashboard that reads data, SSE is the right choice and works natively in Next.js Route Handlers.

Create the Route Handler at app/api/analytics-stream/route.ts:

import { NextRequest } from 'next/server'

export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'

function generateSnapshot() {
  return {
    timestamp: new Date().toISOString(),
    activeUsers: Math.floor(Math.random() * 400) + 100,
    pageViews: Math.floor(Math.random() * 2000) + 500,
    revenue: parseFloat((Math.random() * 800 + 200).toFixed(2)),
    conversionRate: parseFloat((Math.random() * 3 + 1).toFixed(2)),
  }
}

export async function GET(req: NextRequest) {
  const encoder = new TextEncoder()

  const stream = new ReadableStream({
    start(controller) {
      const interval = setInterval(() => {
        const data = JSON.stringify(generateSnapshot())
        controller.enqueue(encoder.encode(`data: ${data}

`))
      }, 5000)

      req.signal.addEventListener('abort', () => {
        clearInterval(interval)
        controller.close()
      })

      // Send an initial snapshot immediately
      const initial = JSON.stringify(generateSnapshot())
      controller.enqueue(encoder.encode(`data: ${initial}

`))
    },
  })

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      Connection: 'keep-alive',
    },
  })
}

Expected result: Visiting /api/analytics-stream in your browser should show raw JSON lines appearing every five seconds.

Common pitfall: Forgetting export const dynamic = 'force-dynamic' causes Next.js to cache the route. The stream will return a stale single response instead of continuous data.

Step 3: Create a Custom Hook to Consume the Stream

Keep data-fetching logic out of your chart components. A dedicated hook makes the stream reusable across the dashboard.

Create app/dashboard/hooks/useAnalyticsStream.ts:

'use client'

import { useEffect, useState } from 'react'

export interface AnalyticsSnapshot {
  timestamp: string
  activeUsers: number
  pageViews: number
  revenue: number
  conversionRate: number
}

const MAX_HISTORY = 20

export function useAnalyticsStream() {
  const [history, setHistory] = useState([])
  const [latest, setLatest] = useState(null)

  useEffect(() => {
    const source = new EventSource('/api/analytics-stream')

    source.onmessage = (event) => {
      const snapshot: AnalyticsSnapshot = JSON.parse(event.data)
      setLatest(snapshot)
      setHistory((prev) => [...prev.slice(-MAX_HISTORY + 1), snapshot])
    }

    source.onerror = () => {
      source.close()
    }

    return () => {
      source.close()
    }
  }, [])

  return { history, latest }
}

The hook keeps the last 20 snapshots in state. That sliding window is what your line chart will plot over time.

Step 4: Build the Metric Cards Component

Create app/dashboard/components/MetricCard.tsx:

'use client'

interface MetricCardProps {
  label: string
  value: string | number
  suffix?: string
}

export function MetricCard({ label, value, suffix }: MetricCardProps) {
  return (
    

{label}

{value} {suffix && ( {suffix} )}

) }

This is intentionally minimal. You can swap the Tailwind classes for your own CSS or a component library like shadcn/ui.

Step 5: Build the Recharts Components

Create app/dashboard/components/ActiveUsersChart.tsx:

'use client'

import {
  LineChart,
  Line,
  XAxis,
  YAxis,
  CartesianGrid,
  Tooltip,
  ResponsiveContainer,
} from 'recharts'
import { AnalyticsSnapshot } from '../hooks/useAnalyticsStream'

interface Props {
  data: AnalyticsSnapshot[]
}

export function ActiveUsersChart({ data }: Props) {
  const formatted = data.map((d) => ({
    time: new Date(d.timestamp).toLocaleTimeString(),
    users: d.activeUsers,
  }))

  return (
    

Active Users (Live)

) }

Note the isAnimationActive={false} prop on the Line. This removes the re-draw animation on every data push. When data arrives every five seconds, the animation causes a jarring reset. Disabling it makes the chart feel like a live feed.

Now create a bar chart for page views at app/dashboard/components/PageViewsChart.tsx:

'use client'

import {
  BarChart,
  Bar,
  XAxis,
  YAxis,
  CartesianGrid,
  Tooltip,
  ResponsiveContainer,
} from 'recharts'
import { AnalyticsSnapshot } from '../hooks/useAnalyticsStream'

interface Props {
  data: AnalyticsSnapshot[]
}

export function PageViewsChart({ data }: Props) {
  const formatted = data.map((d) => ({
    time: new Date(d.timestamp).toLocaleTimeString(),
    views: d.pageViews,
  }))

  return (
    

Page Views (Live)

) }

Step 6: Assemble the Dashboard Page

Create app/dashboard/page.tsx:

'use client'

import { useAnalyticsStream } from './hooks/useAnalyticsStream'
import { MetricCard } from './components/MetricCard'
import { ActiveUsersChart } from './components/ActiveUsersChart'
import { PageViewsChart } from './components/PageViewsChart'

export default function DashboardPage() {
  const { history, latest } = useAnalyticsStream()

  return (
    

Live Analytics

) }

Start the dev server with pnpm dev and navigate to http://localhost:3000/dashboard. You should see metric cards populate immediately, then update every five seconds as new data streams in from your SSE endpoint.

Step 7: Connect a Real Data Source

When should you swap out the mock generator?

The mock generateSnapshot() function in Step 2 is only for prototyping. Before shipping to production, replace it with a real query. Common sources for SMBs include:

  • Supabase: Run a Postgres query inside the interval and return the result as JSON. The Supabase JS client works fine inside a Node.js Route Handler.
  • Google Analytics Data API: Fetch real-time active users from the GA4 Realtime report. The endpoint returns data in under 500ms on average.
  • Your own database: Use Prisma or Drizzle ORM to query aggregated metrics from your application database.

The hook and components do not change at all. You only modify the Route Handler. This is intentional. Separating the stream from the presentation means you can iterate on one without touching the other.

Pro tip: Cache expensive database queries for 4 seconds inside the interval. A five-second SSE push interval with a 4-second cache gives you near-real-time data without hammering your database on every connection.

Step 8: Deploy to Vercel

Next.js 15 SSE routes work on Vercel without any configuration changes. The Node.js runtime you set in Step 2 is supported on all Vercel plans.

Push to your Git remote:

git add .
git commit -m "feat: real-time analytics dashboard"
git push origin main

Vercel will detect the Next.js project and deploy automatically. SSE connections on Vercel are limited to 25 seconds on the Hobby plan due to the function timeout. Upgrade to Pro (60-second timeout) or use Vercel's Streaming functions for longer-lived connections in production.

Common pitfall: If your dashboard works locally but shows no live data on Vercel, check that your SSE route is using the Node.js runtime and not the Edge runtime. The Edge runtime does not support long-lived streaming on all Vercel regions as of August 2026.

Frequently Asked Questions

Does this work with React 18, or do I need React 19?

Recharts 2.12 is compatible with both React 18 and React 19. The SSE hook uses standard useEffect and useState, so it runs on either version without changes.

What if the EventSource connection drops?

The browser's EventSource API reconnects automatically after a dropped connection, usually within 3 seconds. You can customise the retry interval by sending a retry: 3000 field from the server. For production dashboards, add an onerror handler that updates UI state to show a "Reconnecting..." status.

How is this different from using a polling fetch with setInterval?

SSE keeps a single persistent HTTP connection open. Polling opens a new request every interval and closes it. For dashboards refreshing every few seconds, SSE reduces request overhead and server connection load by roughly 60 to 70 percent compared to polling.

Can I add authentication to the SSE endpoint?

Yes. Inside the Route Handler, read the session cookie or Authorization header before starting the stream. If the request is unauthenticated, return a 401 response before creating the ReadableStream. Libraries like Clerk or NextAuth work here because the Route Handler runs on the server.

What if my charts flicker every time new data arrives?

This is almost always caused by isAnimationActive being set to true (the Recharts default). Set it to false on all data series that update frequently. Also check that you are not re-mounting the chart component itself on each state update. The chart component should stay mounted; only its data prop should change.

Next Steps

You now have a working real-time analytics dashboard that streams live data, renders updating Recharts visualisations, and is ready to deploy on Vercel. The next logical steps are to swap in a real data source (Supabase and GA4 are the fastest starting points for most SMBs), add authentication to protect the SSE endpoint, and layer in filters or date-range selectors on the dashboard page.

If your business needs a production-grade dashboard built on this foundation, or you want a fully custom analytics interface designed and developed for your specific data model, the team at Lenka Studio builds exactly these kinds of data products for SMBs across Australia, Singapore, Canada, and the US. We handle architecture, design, and deployment so your team can focus on reading the numbers rather than building the pipes. Get in touch to talk through what you need.