Introduction
Real-time dashboards have moved from a "nice to have" to a baseline expectation. Whether you're running a SaaS product for Canadian logistics teams, an operations tool for an Australian retail chain, or an internal analytics panel for a Singapore-based fintech, users expect data that updates the moment something changes — not after a page refresh.
The good news: building a fully functional real-time dashboard in 2026 no longer requires a dedicated WebSocket server or complex infrastructure. Supabase Realtime — now powered by the Phoenix Framework's Channels and backed by Postgres logical replication — makes it surprisingly straightforward when paired with Next.js 15 and its stable Server Components architecture.
This tutorial walks you through building a live-updating dashboard from scratch: database setup, subscriptions, UI rendering, and performance considerations that matter in production.
What You'll Need
- Node.js 20+ and npm/pnpm installed
- A free Supabase account
- Basic familiarity with React and TypeScript
- Next.js 15 installed (we'll scaffold it in Step 1)
- The Supabase JavaScript client v2.x
Estimated time: 60–90 minutes. All code examples use TypeScript.
Step 1: Scaffold Your Next.js 15 Project
Open your terminal and run:
npx create-next-app@latest realtime-dashboard --typescript --tailwind --app
cd realtime-dashboardWhen prompted, enable the App Router and leave ESLint on. Next.js 15's App Router is essential here — we'll use Server Components for the initial data fetch and a Client Component exclusively for the real-time subscription logic.
Install the Supabase client:
npm install @supabase/supabase-js @supabase/ssrSet Up Environment Variables
Create a .env.local file at the project root:
NEXT_PUBLIC_SUPABASE_URL=your_supabase_project_url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_keyYou'll find both values in your Supabase dashboard under Project Settings → API.
Step 2: Design Your Database Schema in Supabase
For this tutorial, we'll build a dashboard that tracks live order events — a common use case for ecommerce and operations teams. Head to the Supabase SQL Editor and run:
CREATE TABLE orders (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
customer_name text NOT NULL,
amount numeric(10, 2) NOT NULL,
status text CHECK (status IN ('pending', 'processing', 'completed', 'cancelled')) DEFAULT 'pending',
region text NOT NULL,
created_at timestamptz DEFAULT now()
);
-- Enable Row Level Security
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
-- Allow read access for all authenticated users (adjust for your auth model)
CREATE POLICY "Allow read" ON orders FOR SELECT USING (true);Enable Realtime on the Table
In the Supabase dashboard, go to Database → Replication. Find the orders table and toggle on INSERT, UPDATE, and DELETE events. This activates Postgres logical replication for that table, which Supabase Realtime listens to.
Pro tip: Only enable replication on tables you actually need. Enabling it across your entire schema adds unnecessary overhead.
Step 3: Create a Supabase Client Helper
Create lib/supabase/client.ts for use in Client Components:
import { createBrowserClient } from '@supabase/ssr'
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
}And lib/supabase/server.ts for Server Components and Route Handlers:
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
export async function createServerSupabaseClient() {
const cookieStore = await cookies()
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() { return cookieStore.getAll() },
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
},
},
}
)
}Step 4: Fetch Initial Data with a Server Component
This is one of the most important architectural decisions. Fetch the initial snapshot server-side so your dashboard renders with real data on first load — no loading skeleton flicker for the user.
Create app/dashboard/page.tsx:
import { createServerSupabaseClient } from '@/lib/supabase/server'
import OrdersRealtimeTable from '@/components/OrdersRealtimeTable'
export default async function DashboardPage() {
const supabase = await createServerSupabaseClient()
const { data: initialOrders, error } = await supabase
.from('orders')
.select('*')
.order('created_at', { ascending: false })
.limit(50)
if (error) {
console.error('Failed to fetch orders:', error)
}
return (
Live Orders Dashboard
)
}Common pitfall: Don't set up your Realtime subscription inside a Server Component. Server Components run once on the server and cannot maintain a persistent WebSocket connection. Always delegate subscription logic to a Client Component.
Step 5: Build the Realtime Client Component
Create components/OrdersRealtimeTable.tsx:
'use client'
import { useEffect, useState } from 'react'
import { createClient } from '@/lib/supabase/client'
type Order = {
id: string
customer_name: string
amount: number
status: string
region: string
created_at: string
}
export default function OrdersRealtimeTable({
initialOrders,
}: {
initialOrders: Order[]
}) {
const [orders, setOrders] = useState(initialOrders)
const supabase = createClient()
useEffect(() => {
const channel = supabase
.channel('orders-changes')
.on(
'postgres_changes',
{ event: '*', schema: 'public', table: 'orders' },
(payload) => {
if (payload.eventType === 'INSERT') {
setOrders((prev) => [payload.new as Order, ...prev].slice(0, 50))
} else if (payload.eventType === 'UPDATE') {
setOrders((prev) =>
prev.map((o) => (o.id === payload.new.id ? (payload.new as Order) : o))
)
} else if (payload.eventType === 'DELETE') {
setOrders((prev) => prev.filter((o) => o.id !== payload.old.id))
}
}
)
.subscribe()
return () => {
supabase.removeChannel(channel)
}
}, [supabase])
return (
Customer
Amount
Status
Region
Time
{orders.map((order) => (
{order.customer_name}
${order.amount.toFixed(2)}
{order.status}
{order.region}
{new Date(order.created_at).toLocaleTimeString()}
))}
)
} Step 6: Add Aggregate Metrics That Update in Real Time
A useful dashboard shows summary stats, not just a raw table. Add a components/MetricsBar.tsx that derives totals from the same orders state — no extra subscriptions needed:
'use client'
type Order = { amount: number; status: string }
export default function MetricsBar({ orders }: { orders: Order[] }) {
const total = orders.reduce((sum, o) => sum + o.amount, 0)
const completed = orders.filter((o) => o.status === 'completed').length
const pending = orders.filter((o) => o.status === 'pending').length
return (




