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-dashboard

When 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/ssr

Set 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_key

You'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 (
    
        {orders.map((order) => (
          
        ))}
      
Customer Amount Status Region Time
{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 (
    

Total Revenue

${total.toLocaleString('en-AU', { minimumFractionDigits: 2 })}

Completed

{completed}