Most dashboard tutorials show you a static chart with fake data. You follow along, end up with a pretty screenshot, and then realize it does nothing useful. This one is different. We are going to build a dashboard where the charts update the moment new data arrives -- no polling, no page refresh, no WebSocket boilerplate. Data goes into Supabase, and within milliseconds, every connected browser sees the update.
I built a version of this for a client project last quarter. Their team was manually refreshing a Metabase dashboard every 30 seconds to monitor incoming support tickets. After we deployed the real-time version, they told me it "felt like magic." It is not magic -- it is about 200 lines of code on top of Supabase Realtime, Astro, and D3.js.
By the end of this tutorial, you will have a fully functional, deployable analytics dashboard with a live page view counter, a line chart of views over time, a bar chart of top pages, and an activity feed that updates in real-time. All the code is included. You can copy-paste and have it running in under an hour.
What We Are Building
Here is what the finished dashboard does:
- Live page view counter -- a big number that increments the instant a new event arrives
- Line chart -- shows page views over the last 24 hours, updates in real-time with smooth D3 transitions
- Bar chart -- shows the top 10 most-viewed pages, re-sorts itself when data changes
- Activity feed -- a scrolling list of the most recent events with timestamps
All of this updates live. Open the dashboard in two browser tabs, send an event, and both tabs update simultaneously. No refresh needed.
Tech Stack
- Supabase -- Postgres database with Realtime subscriptions (free tier is plenty)
- Astro -- SSR framework with island architecture (perfect for mixing static layout with interactive charts)
- D3.js -- Data visualization library for the charts
- Vercel -- Deployment with SSR support
Prerequisites
Before we start, make sure you have:
- Node.js 18+ installed
- A Supabase account (free tier works)
- Basic familiarity with Astro (if you have used any component framework, you will be fine)
- Basic familiarity with SQL
Step 1: Set Up the Supabase Project
If you do not have a Supabase project yet, create one at supabase.com. Pick the region closest to your users.
Once the project is ready, go to the SQL Editor and run the following:
Create the Analytics Events Table
-- Create the analytics events table
CREATE TABLE analytics_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
event_type text NOT NULL DEFAULT 'page_view',
page_path text NOT NULL,
visitor_id text,
metadata jsonb DEFAULT '{}',
created_at timestamptz DEFAULT now()
);
-- Create indexes for common queries
CREATE INDEX idx_analytics_events_created_at ON analytics_events (created_at DESC);
CREATE INDEX idx_analytics_events_page_path ON analytics_events (page_path);
CREATE INDEX idx_analytics_events_event_type ON analytics_events (event_type);
-- Create a view for hourly aggregation (used by the line chart)
CREATE OR REPLACE VIEW hourly_page_views AS
SELECT
date_trunc('hour', created_at) AS hour,
COUNT(*) AS view_count
FROM analytics_events
WHERE created_at > now() - interval '24 hours'
GROUP BY date_trunc('hour', created_at)
ORDER BY hour;
-- Create a view for top pages (used by the bar chart)
CREATE OR REPLACE VIEW top_pages AS
SELECT
page_path,
COUNT(*) AS view_count
FROM analytics_events
WHERE created_at > now() - interval '24 hours'
GROUP BY page_path
ORDER BY view_count DESC
LIMIT 10;Enable Realtime
This is the critical step. Go to Database > Replication in the Supabase dashboard and enable Realtime for the analytics_events table. Or run this SQL:
ALTER PUBLICATION supabase_realtime ADD TABLE analytics_events;Set Up Row Level Security (RLS)
Security matters even for internal dashboards. We will create a simple policy that allows anonymous reads (for the dashboard) and authenticated inserts (for the event tracking).
-- Enable RLS
ALTER TABLE analytics_events ENABLE ROW LEVEL SECURITY;
-- Allow anyone to read events (the dashboard is public in this tutorial)
CREATE POLICY "Allow public read access"
ON analytics_events
FOR SELECT
TO anon
USING (true);
-- Allow anonymous inserts (for the tracking endpoint)
CREATE POLICY "Allow anonymous inserts"
ON analytics_events
FOR INSERT
TO anon
WITH CHECK (true);In a production app, you would tighten these policies -- restrict inserts to authenticated users or a service role, and potentially limit reads to specific roles. For this tutorial, we keep it open for simplicity.
Seed Some Test Data
Let us insert some test events so the dashboard is not empty:
-- Insert 100 random test events over the last 24 hours
INSERT INTO analytics_events (event_type, page_path, visitor_id, created_at)
SELECT
'page_view',
(ARRAY[
'/', '/about', '/blog', '/contact', '/pricing',
'/blog/getting-started', '/blog/tutorial', '/docs',
'/features', '/changelog'
])[floor(random() * 10 + 1)],
'visitor_' || floor(random() * 50 + 1),
now() - (random() * interval '24 hours')
FROM generate_series(1, 100);Step 2: Scaffold the Astro Project
Open your terminal and create a new Astro project:
npm create astro@latest realtime-dashboard -- --template minimal
cd realtime-dashboardInstall the dependencies we need:
npm install @supabase/supabase-js d3
npm install -D @astrojs/vercelConfigure Astro for SSR
Update astro.config.mjs:
// astro.config.mjs
import { defineConfig } from 'astro/config';
import vercel from '@astrojs/vercel';
export default defineConfig({
output: 'server',
adapter: vercel(),
});Set Up Environment Variables
Create a .env file in the project root:
PUBLIC_SUPABASE_URL=https://your-project-ref.supabase.co
PUBLIC_SUPABASE_ANON_KEY=your-anon-key-hereYou can find these values in your Supabase dashboard under Settings > API. We use the PUBLIC_ prefix because the Supabase client needs to run on the client side for Realtime subscriptions.
Step 3: Build the Supabase Client
Create the Supabase client module that we will use throughout the project.
// src/lib/supabase.ts
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = import.meta.env.PUBLIC_SUPABASE_URL;
const supabaseAnonKey = import.meta.env.PUBLIC_SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseAnonKey) {
throw new Error('Missing Supabase environment variables');
}
export const supabase = createClient(supabaseUrl, supabaseAnonKey);We also need a module for fetching the initial dashboard data on the server:
// src/lib/dashboard-data.ts
import { supabase } from './supabase';
export interface HourlyView {
hour: string;
view_count: number;
}
export interface TopPage {
page_path: string;
view_count: number;
}
export interface AnalyticsEvent {
id: string;
event_type: string;
page_path: string;
visitor_id: string | null;
metadata: Record<string, unknown>;
created_at: string;
}
export async function getTotalViews(): Promise<number> {
const { count, error } = await supabase
.from('analytics_events')
.select('*', { count: 'exact', head: true })
.gte('created_at', new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString());
if (error) throw error;
return count ?? 0;
}
export async function getHourlyViews(): Promise<HourlyView[]> {
const { data, error } = await supabase
.from('hourly_page_views')
.select('*');
if (error) throw error;
return data ?? [];
}
export async function getTopPages(): Promise<TopPage[]> {
const { data, error } = await supabase
.from('top_pages')
.select('*');
if (error) throw error;
return data ?? [];
}
export async function getRecentEvents(limit = 20): Promise<AnalyticsEvent[]> {
const { data, error } = await supabase
.from('analytics_events')
.select('*')
.order('created_at', { ascending: false })
.limit(limit);
if (error) throw error;
return data ?? [];
}Step 4: Create the Dashboard Layout
Now let us build the main dashboard page.
---
// src/pages/index.astro
import DashboardLayout from '../layouts/DashboardLayout.astro';
import LiveCounter from '../components/LiveCounter.astro';
import LineChart from '../components/LineChart.astro';
import BarChart from '../components/BarChart.astro';
import ActivityFeed from '../components/ActivityFeed.astro';
import {
getTotalViews,
getHourlyViews,
getTopPages,
getRecentEvents,
} from '../lib/dashboard-data';
const totalViews = await getTotalViews();
const hourlyViews = await getHourlyViews();
const topPages = await getTopPages();
const recentEvents = await getRecentEvents();
---
<DashboardLayout title="Real-Time Analytics Dashboard">
<div class="dashboard-grid">
<div class="card card-counter">
<h2>Page Views (24h)</h2>
<LiveCounter
client:load
initialCount={totalViews}
/>
</div>
<div class="card card-line-chart">
<h2>Views Over Time</h2>
<LineChart
client:load
initialData={hourlyViews}
/>
</div>
<div class="card card-bar-chart">
<h2>Top Pages</h2>
<BarChart
client:load
initialData={topPages}
/>
</div>
<div class="card card-activity">
<h2>Recent Activity</h2>
<ActivityFeed
client:load
initialEvents={recentEvents}
/>
</div>
</div>
</DashboardLayout>Notice the client:load directives. This is Astro's island architecture in action -- the layout renders on the server, but the chart components hydrate on the client so they can subscribe to Realtime updates and run D3 animations.
The Layout Component
---
// src/layouts/DashboardLayout.astro
interface Props {
title: string;
}
const { title } = Astro.props;
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title}</title>
</head>
<body>
<header>
<h1>{title}</h1>
<div class="status-indicator">
<span class="dot"></span>
Live
</div>
</header>
<main>
<slot />
</main>
</body>
</html>
<style is:global>
:root {
--bg-primary: #0f172a;
--bg-card: #1e293b;
--bg-card-hover: #334155;
--text-primary: #f1f5f9;
--text-secondary: #94a3b8;
--accent: #38bdf8;
--accent-secondary: #818cf8;
--success: #4ade80;
--border: #334155;
--chart-line: #38bdf8;
--chart-bar: #818cf8;
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem 2rem;
border-bottom: 1px solid var(--border);
}
header h1 {
font-size: 1.5rem;
font-weight: 600;
}
.status-indicator {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: var(--success);
}
.dot {
width: 8px;
height: 8px;
background-color: var(--success);
border-radius: 50%;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
main {
padding: 2rem;
}
.dashboard-grid {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: auto auto;
gap: 1.5rem;
max-width: 1400px;
margin: 0 auto;
}
.card {
background-color: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.5rem;
}
.card h2 {
font-size: 0.875rem;
font-weight: 500;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 1rem;
}
.card-counter {
grid-column: 1 / 2;
}
.card-line-chart {
grid-column: 2 / 3;
}
.card-bar-chart {
grid-column: 1 / 2;
}
.card-activity {
grid-column: 2 / 3;
}
@media (max-width: 768px) {
.dashboard-grid {
grid-template-columns: 1fr;
}
.card-counter,
.card-line-chart,
.card-bar-chart,
.card-activity {
grid-column: 1;
}
main {
padding: 1rem;
}
}
</style>Step 5: Build the Interactive Components
Now for the fun part -- the D3 charts with real-time updates. Since Astro uses island architecture, we will write these as framework-agnostic web components using <script> tags within Astro components. This keeps things simple and avoids adding React or Vue as a dependency.
Live Counter Component
---
// src/components/LiveCounter.astro
interface Props {
initialCount: number;
}
const { initialCount } = Astro.props;
---
<div class="counter-container" data-initial-count={initialCount}>
<span class="counter-value">{initialCount.toLocaleString()}</span>
<span class="counter-label">in the last 24 hours</span>
</div>
<style>
.counter-container {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.counter-value {
font-size: 4rem;
font-weight: 700;
font-family: var(--font-mono);
color: var(--accent);
line-height: 1;
transition: transform 0.2s ease;
}
.counter-value.bumped {
transform: scale(1.05);
}
.counter-label {
font-size: 0.875rem;
color: var(--text-secondary);
}
</style>
<script>
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = import.meta.env.PUBLIC_SUPABASE_URL;
const supabaseAnonKey = import.meta.env.PUBLIC_SUPABASE_ANON_KEY;
const supabase = createClient(supabaseUrl, supabaseAnonKey);
const container = document.querySelector('.counter-container') as HTMLElement;
const counterEl = document.querySelector('.counter-value') as HTMLElement;
let count = parseInt(container.dataset.initialCount || '0', 10);
function updateCounter(newCount: number) {
count = newCount;
counterEl.textContent = count.toLocaleString();
// Add a subtle bump animation
counterEl.classList.add('bumped');
setTimeout(() => counterEl.classList.remove('bumped'), 200);
}
// Subscribe to new events
supabase
.channel('counter-updates')
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'analytics_events',
},
() => {
updateCounter(count + 1);
}
)
.subscribe();
</script>Line Chart Component
This is the most complex component. The D3 line chart needs to handle initial rendering, smooth transitions when new data arrives, and responsive resizing.
---
// src/components/LineChart.astro
interface Props {
initialData: Array<{ hour: string; view_count: number }>;
}
const { initialData } = Astro.props;
---
<div class="line-chart-container">
<div class="chart-wrapper" data-initial={JSON.stringify(initialData)}>
<svg class="line-chart"></svg>
</div>
</div>
<style>
.line-chart-container {
width: 100%;
}
.chart-wrapper {
width: 100%;
height: 300px;
position: relative;
}
.line-chart {
width: 100%;
height: 100%;
}
</style>
<script>
import * as d3 from 'd3';
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = import.meta.env.PUBLIC_SUPABASE_URL;
const supabaseAnonKey = import.meta.env.PUBLIC_SUPABASE_ANON_KEY;
const supabase = createClient(supabaseUrl, supabaseAnonKey);
interface HourlyData {
hour: string;
view_count: number;
}
const wrapper = document.querySelector('.chart-wrapper') as HTMLElement;
const svg = d3.select('.line-chart');
const rawData: HourlyData[] = JSON.parse(wrapper.dataset.initial || '[]');
// Parse dates and sort
let data = rawData.map((d) => ({
hour: new Date(d.hour),
view_count: d.view_count,
})).sort((a, b) => a.hour.getTime() - b.hour.getTime());
// Chart dimensions
const margin = { top: 20, right: 30, bottom: 40, left: 50 };
let width: number;
let height: number;
// Scales
let xScale: d3.ScaleTime<number, number>;
let yScale: d3.ScaleLinear<number, number>;
// Line generator
const line = d3.line<{ hour: Date; view_count: number }>()
.x((d) => xScale(d.hour))
.y((d) => yScale(d.view_count))
.curve(d3.curveMonotoneX);
// Area generator for the gradient fill
let area: d3.Area<{ hour: Date; view_count: number }>;
function initChart() {
const rect = wrapper.getBoundingClientRect();
width = rect.width - margin.left - margin.right;
height = rect.height - margin.top - margin.bottom;
svg.selectAll('*').remove();
// Gradient definition
const defs = svg.append('defs');
const gradient = defs.append('linearGradient')
.attr('id', 'area-gradient')
.attr('x1', '0').attr('y1', '0')
.attr('x2', '0').attr('y2', '1');
gradient.append('stop')
.attr('offset', '0%')
.attr('stop-color', 'var(--chart-line)')
.attr('stop-opacity', 0.3);
gradient.append('stop')
.attr('offset', '100%')
.attr('stop-color', 'var(--chart-line)')
.attr('stop-opacity', 0);
const g = svg.append('g')
.attr('transform', `translate(${margin.left},${margin.top})`);
// Scales
xScale = d3.scaleTime()
.domain(d3.extent(data, (d) => d.hour) as [Date, Date])
.range([0, width]);
yScale = d3.scaleLinear()
.domain([0, d3.max(data, (d) => d.view_count) || 10])
.nice()
.range([height, 0]);
// Area generator
area = d3.area<{ hour: Date; view_count: number }>()
.x((d) => xScale(d.hour))
.y0(height)
.y1((d) => yScale(d.view_count))
.curve(d3.curveMonotoneX);
// X axis
g.append('g')
.attr('class', 'x-axis')
.attr('transform', `translate(0,${height})`)
.call(
d3.axisBottom(xScale)
.ticks(6)
.tickFormat((d) => d3.timeFormat('%H:%M')(d as Date))
)
.selectAll('text')
.style('fill', 'var(--text-secondary)')
.style('font-size', '11px');
// Y axis
g.append('g')
.attr('class', 'y-axis')
.call(d3.axisLeft(yScale).ticks(5))
.selectAll('text')
.style('fill', 'var(--text-secondary)')
.style('font-size', '11px');
// Style axis lines
g.selectAll('.domain').style('stroke', 'var(--border)');
g.selectAll('.tick line').style('stroke', 'var(--border)');
// Area fill
g.append('path')
.datum(data)
.attr('class', 'area-path')
.attr('fill', 'url(#area-gradient)')
.attr('d', area);
// Line
g.append('path')
.datum(data)
.attr('class', 'data-line')
.attr('fill', 'none')
.attr('stroke', 'var(--chart-line)')
.attr('stroke-width', 2.5)
.attr('d', line);
// Data points
g.selectAll('.data-point')
.data(data)
.enter()
.append('circle')
.attr('class', 'data-point')
.attr('cx', (d) => xScale(d.hour))
.attr('cy', (d) => yScale(d.view_count))
.attr('r', 3)
.attr('fill', 'var(--chart-line)')
.attr('stroke', 'var(--bg-card)')
.attr('stroke-width', 2);
}
function updateChart() {
const g = svg.select('g');
// Update scales
xScale.domain(d3.extent(data, (d) => d.hour) as [Date, Date]);
yScale.domain([0, d3.max(data, (d) => d.view_count) || 10]).nice();
// Transition the line
g.select('.data-line')
.datum(data)
.transition()
.duration(500)
.attr('d', line);
// Transition the area
g.select('.area-path')
.datum(data)
.transition()
.duration(500)
.attr('d', area);
// Update axes
g.select('.x-axis')
.transition()
.duration(500)
.call(
d3.axisBottom(xScale)
.ticks(6)
.tickFormat((d) => d3.timeFormat('%H:%M')(d as Date)) as any
);
g.select('.y-axis')
.transition()
.duration(500)
.call(d3.axisLeft(yScale).ticks(5) as any);
// Update data points with enter/update/exit pattern
const points = g.selectAll('.data-point').data(data);
points.enter()
.append('circle')
.attr('class', 'data-point')
.attr('r', 0)
.attr('fill', 'var(--chart-line)')
.attr('stroke', 'var(--bg-card)')
.attr('stroke-width', 2)
.merge(points as any)
.transition()
.duration(500)
.attr('cx', (d: any) => xScale(d.hour))
.attr('cy', (d: any) => yScale(d.view_count))
.attr('r', 3);
points.exit().transition().duration(300).attr('r', 0).remove();
}
// Initialize
initChart();
// Handle window resize
let resizeTimer: ReturnType<typeof setTimeout>;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(initChart, 250);
});
// Buffer for high-frequency updates
let updateBuffer: Array<{ hour: Date; view_count: number }> = [];
let updateScheduled = false;
function scheduleUpdate() {
if (updateScheduled) return;
updateScheduled = true;
requestAnimationFrame(() => {
updateChart();
updateScheduled = false;
});
}
// Subscribe to new events
supabase
.channel('line-chart-updates')
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'analytics_events',
},
(payload) => {
const eventTime = new Date(payload.new.created_at);
const hourKey = new Date(eventTime);
hourKey.setMinutes(0, 0, 0);
// Find existing hour bucket or create new one
const existing = data.find(
(d) => d.hour.getTime() === hourKey.getTime()
);
if (existing) {
existing.view_count += 1;
} else {
data.push({ hour: hourKey, view_count: 1 });
data.sort((a, b) => a.hour.getTime() - b.hour.getTime());
}
// Remove data older than 24 hours
const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000);
data = data.filter((d) => d.hour >= cutoff);
scheduleUpdate();
}
)
.subscribe();
</script>Bar Chart Component
---
// src/components/BarChart.astro
interface Props {
initialData: Array<{ page_path: string; view_count: number }>;
}
const { initialData } = Astro.props;
---
<div class="bar-chart-container">
<div class="bar-chart-wrapper" data-initial={JSON.stringify(initialData)}>
<svg class="bar-chart"></svg>
</div>
</div>
<style>
.bar-chart-container {
width: 100%;
}
.bar-chart-wrapper {
width: 100%;
height: 300px;
}
.bar-chart {
width: 100%;
height: 100%;
}
</style>
<script>
import * as d3 from 'd3';
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = import.meta.env.PUBLIC_SUPABASE_URL;
const supabaseAnonKey = import.meta.env.PUBLIC_SUPABASE_ANON_KEY;
const supabase = createClient(supabaseUrl, supabaseAnonKey);
interface PageData {
page_path: string;
view_count: number;
}
const wrapper = document.querySelector('.bar-chart-wrapper') as HTMLElement;
const svg = d3.select('.bar-chart');
let data: PageData[] = JSON.parse(wrapper.dataset.initial || '[]');
const margin = { top: 10, right: 30, bottom: 25, left: 120 };
let width: number;
let height: number;
let xScale: d3.ScaleLinear<number, number>;
let yScale: d3.ScaleBand<string>;
function initBarChart() {
const rect = wrapper.getBoundingClientRect();
width = rect.width - margin.left - margin.right;
height = rect.height - margin.top - margin.bottom;
svg.selectAll('*').remove();
const g = svg.append('g')
.attr('transform', `translate(${margin.left},${margin.top})`);
// Scales
xScale = d3.scaleLinear()
.domain([0, d3.max(data, (d) => d.view_count) || 10])
.range([0, width]);
yScale = d3.scaleBand()
.domain(data.map((d) => d.page_path))
.range([0, height])
.padding(0.3);
// X axis
g.append('g')
.attr('class', 'x-axis')
.attr('transform', `translate(0,${height})`)
.call(d3.axisBottom(xScale).ticks(5))
.selectAll('text')
.style('fill', 'var(--text-secondary)')
.style('font-size', '11px');
// Y axis (page paths)
g.append('g')
.attr('class', 'y-axis')
.call(d3.axisLeft(yScale))
.selectAll('text')
.style('fill', 'var(--text-secondary)')
.style('font-size', '11px')
.each(function () {
const el = d3.select(this);
const text = el.text();
if (text.length > 18) {
el.text(text.substring(0, 18) + '...');
}
});
// Style axis lines
g.selectAll('.domain').style('stroke', 'var(--border)');
g.selectAll('.tick line').style('stroke', 'var(--border)');
// Bars
g.selectAll('.bar')
.data(data)
.enter()
.append('rect')
.attr('class', 'bar')
.attr('x', 0)
.attr('y', (d) => yScale(d.page_path) || 0)
.attr('height', yScale.bandwidth())
.attr('fill', 'var(--chart-bar)')
.attr('rx', 4)
.attr('width', 0)
.transition()
.duration(600)
.attr('width', (d) => xScale(d.view_count));
// Value labels
g.selectAll('.bar-label')
.data(data)
.enter()
.append('text')
.attr('class', 'bar-label')
.attr('x', (d) => xScale(d.view_count) + 6)
.attr('y', (d) => (yScale(d.page_path) || 0) + yScale.bandwidth() / 2)
.attr('dy', '0.35em')
.style('fill', 'var(--text-secondary)')
.style('font-size', '12px')
.style('font-family', 'var(--font-mono)')
.text((d) => d.view_count);
}
function updateBarChart() {
// Sort data by view count descending
data.sort((a, b) => b.view_count - a.view_count);
// Keep only top 10
data = data.slice(0, 10);
const g = svg.select('g');
// Update scales
xScale.domain([0, d3.max(data, (d) => d.view_count) || 10]);
yScale.domain(data.map((d) => d.page_path));
// Update bars with enter/update/exit
const bars = g.selectAll('.bar').data(data, (d: any) => d.page_path);
bars.enter()
.append('rect')
.attr('class', 'bar')
.attr('x', 0)
.attr('y', (d) => yScale(d.page_path) || 0)
.attr('height', yScale.bandwidth())
.attr('fill', 'var(--chart-bar)')
.attr('rx', 4)
.attr('width', 0)
.merge(bars as any)
.transition()
.duration(400)
.attr('y', (d: any) => yScale(d.page_path) || 0)
.attr('height', yScale.bandwidth())
.attr('width', (d: any) => xScale(d.view_count));
bars.exit().transition().duration(300).attr('width', 0).remove();
// Update labels
const labels = g.selectAll('.bar-label').data(data, (d: any) => d.page_path);
labels.enter()
.append('text')
.attr('class', 'bar-label')
.style('fill', 'var(--text-secondary)')
.style('font-size', '12px')
.style('font-family', 'var(--font-mono)')
.merge(labels as any)
.transition()
.duration(400)
.attr('x', (d: any) => xScale(d.view_count) + 6)
.attr('y', (d: any) => (yScale(d.page_path) || 0) + yScale.bandwidth() / 2)
.attr('dy', '0.35em')
.text((d: any) => d.view_count);
labels.exit().remove();
// Update axes
g.select('.x-axis')
.transition()
.duration(400)
.call(d3.axisBottom(xScale).ticks(5) as any);
g.select('.y-axis')
.transition()
.duration(400)
.call(d3.axisLeft(yScale) as any);
}
// Initialize
initBarChart();
// Handle resize
let resizeTimer: ReturnType<typeof setTimeout>;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(initBarChart, 250);
});
// Subscribe to updates
supabase
.channel('bar-chart-updates')
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'analytics_events',
},
(payload) => {
const pagePath = payload.new.page_path;
const existing = data.find((d) => d.page_path === pagePath);
if (existing) {
existing.view_count += 1;
} else {
data.push({ page_path: pagePath, view_count: 1 });
}
updateBarChart();
}
)
.subscribe();
</script>Activity Feed Component
---
// src/components/ActivityFeed.astro
interface Props {
initialEvents: Array<{
id: string;
event_type: string;
page_path: string;
visitor_id: string | null;
created_at: string;
}>;
}
const { initialEvents } = Astro.props;
---
<div class="activity-feed" data-initial={JSON.stringify(initialEvents)}>
<ul class="event-list">
{initialEvents.slice(0, 15).map((event) => (
<li class="event-item">
<span class="event-dot"></span>
<div class="event-content">
<span class="event-path">{event.page_path}</span>
<span class="event-time">
{new Date(event.created_at).toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})}
</span>
</div>
</li>
))}
</ul>
</div>
<style>
.activity-feed {
max-height: 300px;
overflow-y: auto;
}
.event-list {
list-style: none;
padding: 0;
margin: 0;
}
.event-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0;
border-bottom: 1px solid var(--border);
animation: none;
}
.event-item.new-event {
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.event-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background-color: var(--accent);
flex-shrink: 0;
}
.event-item.new-event .event-dot {
background-color: var(--success);
}
.event-content {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
gap: 0.5rem;
}
.event-path {
font-family: var(--font-mono);
font-size: 0.8125rem;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.event-time {
font-size: 0.75rem;
color: var(--text-secondary);
white-space: nowrap;
flex-shrink: 0;
}
</style>
<script>
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = import.meta.env.PUBLIC_SUPABASE_URL;
const supabaseAnonKey = import.meta.env.PUBLIC_SUPABASE_ANON_KEY;
const supabase = createClient(supabaseUrl, supabaseAnonKey);
const eventList = document.querySelector('.event-list') as HTMLUListElement;
const MAX_EVENTS = 15;
function addEvent(event: {
page_path: string;
created_at: string;
}) {
const li = document.createElement('li');
li.className = 'event-item new-event';
const time = new Date(event.created_at).toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
li.innerHTML = `
<span class="event-dot"></span>
<div class="event-content">
<span class="event-path">${event.page_path}</span>
<span class="event-time">${time}</span>
</div>
`;
// Insert at the top
eventList.insertBefore(li, eventList.firstChild);
// Remove the "new" class after animation completes
setTimeout(() => li.classList.remove('new-event'), 300);
// Remove old events if we exceed the limit
while (eventList.children.length > MAX_EVENTS) {
eventList.removeChild(eventList.lastChild!);
}
}
// Subscribe to new events
supabase
.channel('activity-feed')
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'analytics_events',
},
(payload) => {
addEvent({
page_path: payload.new.page_path,
created_at: payload.new.created_at,
});
}
)
.subscribe();
</script>Step 6: Understanding the Realtime Wiring
Let me break down what is happening with the Supabase Realtime subscriptions, because this is the core of the whole dashboard.
How Supabase Realtime Works
Supabase Realtime uses PostgreSQL's built-in replication system. When you insert a row into the analytics_events table, PostgreSQL generates a WAL (Write-Ahead Log) event. Supabase's Realtime server listens to these WAL events and broadcasts them to all connected WebSocket clients.
The flow looks like this:
Data inserted into Postgres
--> PostgreSQL generates WAL event
--> Supabase Realtime server receives WAL event
--> Realtime server broadcasts to all WebSocket subscribers
--> Browser receives event via WebSocket
--> D3 chart updates with smooth transitionThis entire chain typically completes in 50-150ms. That is fast enough that updates feel instantaneous.
Channel Architecture
Each component subscribes to its own Realtime channel:
counter-updates --> LiveCounter component
line-chart-updates --> LineChart component
bar-chart-updates --> BarChart component
activity-feed --> ActivityFeed componentWhy separate channels? Because each component processes the event differently. The counter increments a number. The line chart updates an hourly bucket. The bar chart updates a page path counter. The activity feed prepends a DOM element. Using separate channels keeps the logic clean and avoids one component's handler interfering with another.
Important Gotcha: Shared State
You might notice that all four components subscribe to the same table (analytics_events) with the same event type (INSERT). This means each insert triggers four WebSocket messages -- one for each channel. For a dashboard with moderate traffic (a few events per second), this is fine.
For high-frequency dashboards (hundreds of events per second), you would want to:
- Use a single channel and dispatch events to components from a central handler
- Debounce chart updates (D3 transitions every 500ms, not every event)
- Batch events on the server side before writing to the database
We handle the debouncing in the line chart component with requestAnimationFrame, which naturally limits updates to the browser's render cycle (typically 60fps).
Step 7: Add a Test Event Sender
Let us add a simple way to send test events so you can see the real-time updates in action. Create a test page:
---
// src/pages/send-event.astro
export const prerender = false;
---
<html>
<head>
<title>Send Test Event</title>
<style>
body {
font-family: sans-serif;
padding: 2rem;
background: #0f172a;
color: #f1f5f9;
}
.form-group {
margin-bottom: 1rem;
}
label {
display: block;
margin-bottom: 0.25rem;
font-size: 0.875rem;
color: #94a3b8;
}
select, button {
padding: 0.5rem 1rem;
border: 1px solid #334155;
border-radius: 6px;
background: #1e293b;
color: #f1f5f9;
font-size: 1rem;
}
button {
cursor: pointer;
background: #38bdf8;
color: #0f172a;
border: none;
font-weight: 600;
}
button:hover {
background: #7dd3fc;
}
.result {
margin-top: 1rem;
padding: 0.75rem;
background: #1e293b;
border-radius: 6px;
font-family: monospace;
font-size: 0.875rem;
}
</style>
</head>
<body>
<h1>Send Test Event</h1>
<p>Open the <a href="/" style="color: #38bdf8">dashboard</a> in another tab to see real-time updates.</p>
<div class="form-group">
<label>Page Path</label>
<select id="page-path">
<option value="/">/</option>
<option value="/about">/about</option>
<option value="/blog">/blog</option>
<option value="/contact">/contact</option>
<option value="/pricing">/pricing</option>
<option value="/blog/getting-started">/blog/getting-started</option>
<option value="/docs">/docs</option>
<option value="/features">/features</option>
</select>
</div>
<button id="send-btn">Send Event</button>
<button id="burst-btn">Send 10 Events (burst)</button>
<div class="result" id="result"></div>
<script>
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = import.meta.env.PUBLIC_SUPABASE_URL;
const supabaseAnonKey = import.meta.env.PUBLIC_SUPABASE_ANON_KEY;
const supabase = createClient(supabaseUrl, supabaseAnonKey);
const pagePathEl = document.getElementById('page-path') as HTMLSelectElement;
const sendBtn = document.getElementById('send-btn') as HTMLButtonElement;
const burstBtn = document.getElementById('burst-btn') as HTMLButtonElement;
const resultEl = document.getElementById('result') as HTMLDivElement;
async function sendEvent() {
const { data, error } = await supabase
.from('analytics_events')
.insert({
event_type: 'page_view',
page_path: pagePathEl.value,
visitor_id: 'test_visitor_' + Math.floor(Math.random() * 100),
})
.select()
.single();
if (error) {
resultEl.textContent = 'Error: ' + error.message;
} else {
resultEl.textContent = 'Sent: ' + JSON.stringify(data, null, 2);
}
}
sendBtn.addEventListener('click', sendEvent);
burstBtn.addEventListener('click', async () => {
const paths = ['/', '/about', '/blog', '/contact', '/pricing',
'/blog/getting-started', '/docs', '/features'];
const promises = Array.from({ length: 10 }, () => {
const randomPath = paths[Math.floor(Math.random() * paths.length)];
return supabase.from('analytics_events').insert({
event_type: 'page_view',
page_path: randomPath,
visitor_id: 'test_visitor_' + Math.floor(Math.random() * 100),
});
});
await Promise.all(promises);
resultEl.textContent = 'Sent 10 burst events!';
});
</script>
</body>
</html>Step 8: Deploy to Vercel
The project is ready for deployment. Here are the steps:
Push to GitHub
git init
git add .
git commit -m "Initial real-time dashboard"
git remote add origin https://github.com/your-username/realtime-dashboard.git
git push -u origin mainDeploy on Vercel
npm install -g vercel
vercelFollow the prompts. When asked about the framework, select "Astro."
Set Environment Variables
In the Vercel dashboard, go to your project settings and add:
PUBLIC_SUPABASE_URL-- your Supabase project URLPUBLIC_SUPABASE_ANON_KEY-- your Supabase anonymous key
Redeploy after adding the variables:
vercel --prodYour real-time dashboard is now live. Open it in two browser tabs, send an event from the /send-event page, and watch both tabs update simultaneously.
Performance Considerations
Debouncing High-Frequency Updates
If your dashboard receives hundreds of events per second, updating D3 charts on every single event will cause performance problems. The line chart component already uses requestAnimationFrame for debouncing, but you can add additional throttling:
// Throttle updates to once every 500ms
let lastUpdate = 0;
const THROTTLE_MS = 500;
function throttledUpdate() {
const now = Date.now();
if (now - lastUpdate < THROTTLE_MS) return;
lastUpdate = now;
updateChart();
}Chart Rendering Optimization
D3 transitions are GPU-accelerated when you animate transform and opacity properties. Avoid animating width and height on SVG elements if possible -- use transform: scaleX() instead for horizontal bar charts. For the scope of this tutorial, the simple width animation works fine for up to ~50 bars.
Connection Management
Supabase Realtime connections are persistent WebSocket connections. Each browser tab opens its own connections. If users have many tabs open, this can consume connections on the Supabase side. The free tier supports up to 200 concurrent connections, which is plenty for most dashboards.
For high-traffic scenarios, consider using a shared worker or service worker to maintain a single WebSocket connection across tabs and broadcast events via BroadcastChannel.
Extensions: Where to Go from Here
Here are ideas for extending this dashboard:
Date Range Picker
Add a date selector that re-queries the database for a specific time range:
async function fetchDataForRange(start: Date, end: Date) {
const { data } = await supabase
.from('analytics_events')
.select('*')
.gte('created_at', start.toISOString())
.lte('created_at', end.toISOString());
return data;
}Filters
Add dropdown filters for event_type or page_path. Supabase Realtime supports filter conditions on subscriptions:
supabase
.channel('filtered-events')
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'analytics_events',
filter: 'page_path=eq./blog',
},
handleEvent
)
.subscribe();Export to CSV
Add a download button that exports the current data:
function exportToCSV(data: any[], filename: string) {
const headers = Object.keys(data[0]).join(',');
const rows = data.map((row) => Object.values(row).join(','));
const csv = [headers, ...rows].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
}Multiple Dashboard Pages
Create separate dashboard pages for different views (e.g., /dashboard/traffic, /dashboard/events, /dashboard/users) and use Astro's file-based routing.
Full Project Structure
Here is the complete file tree for reference:
realtime-dashboard/
├── astro.config.mjs
├── package.json
├── .env
├── src/
│ ├── lib/
│ │ ├── supabase.ts
│ │ └── dashboard-data.ts
│ ├── layouts/
│ │ └── DashboardLayout.astro
│ ├── components/
│ │ ├── LiveCounter.astro
│ │ ├── LineChart.astro
│ │ ├── BarChart.astro
│ │ └── ActivityFeed.astro
│ └── pages/
│ ├── index.astro
│ └── send-event.astro
└── public/That is it. About 600 lines of code total for a production-ready, real-time analytics dashboard. The Supabase Realtime layer handles all the WebSocket complexity, Astro's island architecture keeps the page fast (static layout, interactive charts), and D3 makes the visualizations smooth and professional.
The combination of these three tools is something we use regularly at CODERCOPS for client projects that need live data visualization. The development speed is remarkable -- you get real-time capability without writing a single line of WebSocket code.
Want a Custom Dashboard for Your Business?
At CODERCOPS, we build real-time dashboards, analytics platforms, and data visualization tools using Supabase, Astro, and D3. If you need a dashboard that goes beyond what off-the-shelf tools offer, get in touch.
We have built dashboards for fintech startups, SaaS analytics platforms, and internal operations teams -- all with the kind of real-time, buttery-smooth experience you just built in this tutorial.
For more tutorials and engineering deep-dives, check out the CODERCOPS blog.
Comments