Real-time Features: Supabase Realtime and Analytics
Learning Objectives
- Understand the differences between HTTP, WebSocket, and SSE
- Use Supabase Realtime to instantly reflect profile changes
- Build a link click tracking system
- Create a click analytics dashboard
- Implement a live preview feature
4.1 What is Real-time Communication?
HTTP Limitations
The HTTP we've used so far is a 'request-response' model:
- Client requests -> Server responds
- Server cannot send messages first
- Must keep requesting to check for new data (polling)
What is WebSocket?
WebSocket enables bidirectional real-time communication:
- Once connected, stays connected
- Server can also send messages first
- Used for chat, games, stock prices, etc.
What is SSE (Server-Sent Events)?
SSE provides one-way streaming from server to client:
- Only server -> client direction
- Simple setup since it's HTTP-based
- Suitable for real-time notifications, feed updates, etc.
- Supabase Realtime uses WebSocket internally
LinkHub Architecture
LinkHub's real-time features consist of three parts:
[User Browser] -> [LinkHub Server] -> [Supabase Realtime]
^ |
Real-time Updates DB Change Detection (INSERT/UPDATE/DELETE)Where LinkHub needs real-time capabilities:
| Feature | Description | Method |
|---|---|---|
| Profile Editing | Changes instantly reflected on public page | Supabase Realtime |
| Click Tracking | Record when visitors click links | DB INSERT + Realtime |
| Analytics Dashboard | Display click stats in real-time | Supabase Subscription |
| Live Preview | Update preview panel while editing | React State + Realtime |
4.2 Instant Link Updates with Supabase Realtime
When you edit a profile, it should be instantly reflected on the public page (e.g., linkhub.com/username). With Supabase Realtime, you can implement this without a separate WebSocket server.
Enabling Supabase Realtime
Enable Realtime in Supabase Dashboard
Go to Supabase Dashboard -> Database -> Replication and enable Realtime for the links and profiles tables.
Verify Table Structure
-- profiles table
CREATE TABLE profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES auth.users(id),
username TEXT UNIQUE NOT NULL,
display_name TEXT,
bio TEXT,
avatar_url TEXT,
theme TEXT DEFAULT 'default',
updated_at TIMESTAMPTZ DEFAULT now()
);
-- links table
CREATE TABLE links (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
profile_id UUID REFERENCES profiles(id) ON DELETE CASCADE,
title TEXT NOT NULL,
url TEXT NOT NULL,
icon TEXT,
sort_order INTEGER DEFAULT 0,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);Implement Real-time Subscriptions
Real-time Subscription on Public Profile Page
// app/[username]/page.tsx (or the corresponding public page component)
'use client';
import { useEffect, useState } from 'react';
import { supabase } from '@/lib/supabase';
export default function PublicProfile({ username }: { username: string }) {
const [profile, setProfile] = useState(null);
const [links, setLinks] = useState([]);
useEffect(() => {
// Load initial data
async function loadProfile() {
const { data: profileData } = await supabase
.from('profiles')
.select('*')
.eq('username', username)
.single();
setProfile(profileData);
if (profileData) {
const { data: linksData } = await supabase
.from('links')
.select('*')
.eq('profile_id', profileData.id)
.eq('is_active', true)
.order('sort_order');
setLinks(linksData || []);
}
}
loadProfile();
// Subscribe to profile changes in real-time
const profileChannel = supabase
.channel('profile-changes')
.on(
'postgres_changes',
{
event: 'UPDATE',
schema: 'public',
table: 'profiles',
filter: `username=eq.${username}`
},
(payload) => {
setProfile(payload.new);
}
)
.subscribe();
// Subscribe to link changes in real-time
const linksChannel = supabase
.channel('links-changes')
.on(
'postgres_changes',
{
event: '*', // Detect INSERT, UPDATE, DELETE
schema: 'public',
table: 'links'
},
async () => {
// Reload all links when changes occur
if (profile) {
const { data } = await supabase
.from('links')
.select('*')
.eq('profile_id', profile.id)
.eq('is_active', true)
.order('sort_order');
setLinks(data || []);
}
}
)
.subscribe();
return () => {
supabase.removeChannel(profileChannel);
supabase.removeChannel(linksChannel);
};
}, [username]);
if (!profile) return <div>Loading...</div>;
return (
<div className="max-w-md mx-auto p-6">
<div className="text-center mb-8">
<img src={profile.avatar_url} alt="" className="w-24 h-24 rounded-full mx-auto" />
<h1 className="text-xl font-bold mt-4">{profile.display_name}</h1>
<p className="text-gray-500">{profile.bio}</p>
</div>
<div className="space-y-3">
{links.map((link) => (
<a
key={link.id}
href={link.url}
className="block p-4 bg-white rounded-lg shadow text-center hover:scale-105 transition"
>
{link.icon && <span className="mr-2">{link.icon}</span>}
{link.title}
</a>
))}
</div>
</div>
);
}Advantages of Supabase Realtime
It automatically detects DB INSERT/UPDATE/DELETE. Real-time sync works without any separate event dispatch code. When you press the save button on the profile edit page, the public page updates automatically.
4.3 Click Tracking (Click Analytics)
One of LinkHub's core values is telling you 'who clicked your links and how many times'.
Click Table Design
-- clicks table
CREATE TABLE clicks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
link_id UUID REFERENCES links(id) ON DELETE CASCADE,
profile_id UUID REFERENCES profiles(id) ON DELETE CASCADE,
clicked_at TIMESTAMPTZ DEFAULT now(),
referrer TEXT,
country TEXT,
device TEXT,
browser TEXT
);
-- Indexes for fast lookups
CREATE INDEX idx_clicks_link_id ON clicks(link_id);
CREATE INDEX idx_clicks_profile_id ON clicks(profile_id);
CREATE INDEX idx_clicks_clicked_at ON clicks(clicked_at);Click Tracking API Implementation
When a visitor clicks a link, we record the click information and redirect to the original URL.
// app/api/click/[linkId]/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY! // Use service role on the server
);
export async function GET(
request: NextRequest,
{ params }: { params: { linkId: string } }
) {
const linkId = params.linkId;
// Look up link info
const { data: link } = await supabase
.from('links')
.select('url, profile_id')
.eq('id', linkId)
.single();
if (!link) {
return NextResponse.redirect('/404');
}
// Record click info (async, does not delay redirect)
const userAgent = request.headers.get('user-agent') || '';
const referrer = request.headers.get('referer') || '';
supabase.from('clicks').insert({
link_id: linkId,
profile_id: link.profile_id,
referrer: referrer,
device: detectDevice(userAgent),
browser: detectBrowser(userAgent),
});
// Redirect to the original URL
return NextResponse.redirect(link.url);
}
function detectDevice(ua: string): string {
if (/mobile/i.test(ua)) return 'mobile';
if (/tablet/i.test(ua)) return 'tablet';
return 'desktop';
}
function detectBrowser(ua: string): string {
if (/chrome/i.test(ua)) return 'Chrome';
if (/firefox/i.test(ua)) return 'Firefox';
if (/safari/i.test(ua)) return 'Safari';
return 'Other';
}Change Public Page Links to Tracking URLs
// Before: navigate directly to URL
<a href={link.url}>{link.title}</a>
// After: navigate through click tracking API
<a href={`/api/click/${link.id}`}>{link.title}</a>Click Tracking Considerations
Process click recording asynchronously. If you wait for the DB INSERT to complete before redirecting, user experience suffers. It's best to use a fire-and-forget approach without await.
4.4 Analytics Dashboard
We'll build a dashboard that visualizes the collected click data.
Analytics Data Queries
// lib/analytics.ts
import { supabase } from '@/lib/supabase';
// Get total click count
export async function getTotalClicks(profileId: string) {
const { count } = await supabase
.from('clicks')
.select('*', { count: 'exact', head: true })
.eq('profile_id', profileId);
return count || 0;
}
// Get clicks by link
export async function getClicksByLink(profileId: string) {
const { data } = await supabase
.from('clicks')
.select(`
link_id,
links!inner(title, url)
`)
.eq('profile_id', profileId);
// Group by link
const grouped = (data || []).reduce((acc, click) => {
const key = click.link_id;
if (!acc[key]) {
acc[key] = {
linkId: key,
title: click.links.title,
url: click.links.url,
count: 0
};
}
acc[key].count++;
return acc;
}, {});
return Object.values(grouped).sort((a, b) => b.count - a.count);
}
// Get daily clicks (last 7 days)
export async function getDailyClicks(profileId: string) {
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
const { data } = await supabase
.from('clicks')
.select('clicked_at')
.eq('profile_id', profileId)
.gte('clicked_at', sevenDaysAgo.toISOString());
// Group by date
const daily = (data || []).reduce((acc, click) => {
const date = new Date(click.clicked_at).toLocaleDateString('ko-KR');
acc[date] = (acc[date] || 0) + 1;
return acc;
}, {});
return Object.entries(daily).map(([date, count]) => ({ date, count }));
}
// Get device distribution
export async function getDeviceStats(profileId: string) {
const { data } = await supabase
.from('clicks')
.select('device')
.eq('profile_id', profileId);
const stats = (data || []).reduce((acc, click) => {
acc[click.device] = (acc[click.device] || 0) + 1;
return acc;
}, {});
return stats; // { mobile: 150, desktop: 80, tablet: 20 }
}Dashboard UI Prompt
Create a LinkHub analytics dashboard page.
Top cards:
- Total clicks, today's clicks, this week's clicks, most popular link
Charts (using recharts):
- Daily click trends (last 7 days) - line chart
- Click ratio by link - bar chart
- Device distribution - pie chart
Table:
- Detailed click stats by link (link title, URL, click count, last click date)
Real-time updates:
- Auto-refresh via Supabase Realtime when new clicks occurReal-time Dashboard Implementation
// app/dashboard/analytics/page.tsx
'use client';
import { useEffect, useState } from 'react';
import { supabase } from '@/lib/supabase';
import {
getTotalClicks,
getClicksByLink,
getDailyClicks,
getDeviceStats
} from '@/lib/analytics';
export default function AnalyticsDashboard() {
const [totalClicks, setTotalClicks] = useState(0);
const [clicksByLink, setClicksByLink] = useState([]);
const [dailyClicks, setDailyClicks] = useState([]);
const [deviceStats, setDeviceStats] = useState({});
const [profileId, setProfileId] = useState('');
// Data loading function
async function loadAnalytics(pid: string) {
const [total, byLink, daily, devices] = await Promise.all([
getTotalClicks(pid),
getClicksByLink(pid),
getDailyClicks(pid),
getDeviceStats(pid)
]);
setTotalClicks(total);
setClicksByLink(byLink);
setDailyClicks(daily);
setDeviceStats(devices);
}
useEffect(() => {
// Get current user's profile ID
async function init() {
const { data: { user } } = await supabase.auth.getUser();
if (!user) return;
const { data: profile } = await supabase
.from('profiles')
.select('id')
.eq('user_id', user.id)
.single();
if (profile) {
setProfileId(profile.id);
loadAnalytics(profile.id);
}
}
init();
}, []);
// Subscribe to real-time clicks
useEffect(() => {
if (!profileId) return;
const channel = supabase
.channel('new-clicks')
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'clicks',
filter: `profile_id=eq.${profileId}`
},
() => {
// Refresh all stats when a new click occurs
loadAnalytics(profileId);
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [profileId]);
return (
<div className="p-6 space-y-6">
<h1 className="text-2xl font-bold">Click Analytics</h1>
{/* Summary cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="p-4 bg-white rounded-lg shadow">
<p className="text-sm text-gray-500">Total Clicks</p>
<p className="text-3xl font-bold">{totalClicks}</p>
</div>
<div className="p-4 bg-white rounded-lg shadow">
<p className="text-sm text-gray-500">Number of Links</p>
<p className="text-3xl font-bold">{clicksByLink.length}</p>
</div>
<div className="p-4 bg-white rounded-lg shadow">
<p className="text-sm text-gray-500">Today's Clicks</p>
<p className="text-3xl font-bold">
{dailyClicks.find(d =>
d.date === new Date().toLocaleDateString('ko-KR')
)?.count || 0}
</p>
</div>
</div>
{/* Click ranking by link */}
<div className="bg-white rounded-lg shadow p-4">
<h2 className="text-lg font-semibold mb-4">Click Ranking by Link</h2>
<table className="w-full">
<thead>
<tr className="text-left text-sm text-gray-500">
<th className="pb-2">Link</th>
<th className="pb-2">Clicks</th>
</tr>
</thead>
<tbody>
{clicksByLink.map((item) => (
<tr key={item.linkId} className="border-t">
<td className="py-2">
<p className="font-medium">{item.title}</p>
<p className="text-sm text-gray-400">{item.url}</p>
</td>
<td className="py-2 font-mono">{item.count}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}Recommended Chart Library
Using recharts, you can easily draw charts in React. Use LineChart for daily click trends, BarChart for link comparisons, and PieChart for device distribution.
4.5 Live Preview
We'll make it possible to check a real-time preview while editing, even before saving changes.
Edit + Preview Layout
Create the profile edit page with a left-right split layout.
Left (Edit panel):
- Profile photo upload
- Display name, bio input
- Add/delete/reorder links (drag and drop)
- Theme selection (colors, fonts)
Right (Preview panel):
- Actual public page preview inside a mobile frame
- Instantly reflects changes from the left panel
- View changes before savingReal-time Preview Using React State
// app/dashboard/edit/page.tsx
'use client';
import { useState } from 'react';
import ProfileEditor from '@/components/ProfileEditor';
import ProfilePreview from '@/components/ProfilePreview';
export default function EditPage() {
// Manage edit state at the parent level
const [profile, setProfile] = useState({
displayName: '',
bio: '',
avatarUrl: '',
theme: 'default'
});
const [links, setLinks] = useState([]);
return (
<div className="flex h-screen">
{/* Left: Edit panel */}
<div className="w-1/2 overflow-y-auto p-6 border-r">
<ProfileEditor
profile={profile}
links={links}
onProfileChange={setProfile}
onLinksChange={setLinks}
/>
</div>
{/* Right: Preview panel */}
<div className="w-1/2 bg-gray-100 flex items-center justify-center">
<div className="w-[375px] h-[667px] bg-white rounded-3xl shadow-xl overflow-hidden border-4 border-gray-800">
<ProfilePreview profile={profile} links={links} />
</div>
</div>
</div>
);
}Preview Component
// components/ProfilePreview.tsx
'use client';
interface ProfilePreviewProps {
profile: {
displayName: string;
bio: string;
avatarUrl: string;
theme: string;
};
links: Array<{
id: string;
title: string;
url: string;
icon?: string;
}>;
}
export default function ProfilePreview({ profile, links }: ProfilePreviewProps) {
return (
<div className="h-full overflow-y-auto p-6">
{/* Profile header */}
<div className="text-center mb-6">
{profile.avatarUrl ? (
<img
src={profile.avatarUrl}
alt=""
className="w-20 h-20 rounded-full mx-auto object-cover"
/>
) : (
<div className="w-20 h-20 rounded-full mx-auto bg-gray-200" />
)}
<h2 className="text-lg font-bold mt-3">
{profile.displayName || 'Enter your name'}
</h2>
<p className="text-sm text-gray-500 mt-1">
{profile.bio || 'Enter your bio'}
</p>
</div>
{/* Link list */}
<div className="space-y-3">
{links.length === 0 && (
<p className="text-center text-gray-400 text-sm">
Try adding some links
</p>
)}
{links.map((link) => (
<div
key={link.id}
className="block p-3 bg-white border rounded-lg text-center
hover:shadow-md transition cursor-pointer"
>
{link.icon && <span className="mr-2">{link.icon}</span>}
{link.title || 'Link title'}
</div>
))}
</div>
</div>
);
}Saving to Supabase
async function handleSave() {
setSaving(true);
// Update profile
await supabase
.from('profiles')
.update({
display_name: profile.displayName,
bio: profile.bio,
avatar_url: profile.avatarUrl,
theme: profile.theme,
updated_at: new Date().toISOString()
})
.eq('id', profileId);
// Update links (delete existing, then re-insert)
await supabase
.from('links')
.delete()
.eq('profile_id', profileId);
await supabase
.from('links')
.insert(
links.map((link, index) => ({
profile_id: profileId,
title: link.title,
url: link.url,
icon: link.icon,
sort_order: index,
is_active: true
}))
);
setSaving(false);
toast.success('Saved successfully!');
// Supabase Realtime automatically delivers changes to the public page
}Local State vs Supabase Realtime
The preview reflects changes instantly using only React state (no network needed). When you press the save button, it's saved to Supabase, and then delivered to the public page via Realtime. It's important to distinguish between these two approaches.
4.6 Mobile Optimization
LinkHub's core users share and manage links on mobile. We need to optimize the mobile experience.
PWA (Progressive Web App) Setup
Setting up PWA allows installation like an app without the browser address bar.
Add PWA setup to Next.js.
Include:
- manifest.json (app name, icons, colors)
- 192x192, 512x512 icon placeholders
- PWA settings in next.config.js
- "Add to Home Screen" prompt banner// public/manifest.json
{
"name": "LinkHub",
"short_name": "LinkHub",
"start_url": "/dashboard",
"display": "standalone",
"background_color": "#000000",
"theme_color": "#7c3aed",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}Mobile UI Optimization
- Large touch targets (44px minimum)
- Input field visible when keyboard is up
- Swipe gesture support
- Dark mode support
4.7 Other Real-time Options
Besides Supabase Realtime, there are other services for implementing real-time communication.
Options Comparison
| Service | Features | Free Tier | Recommended For |
|---|---|---|---|
| Socket.io | Most popular, requires own server | Free (self-hosted) | Full control needed |
| Pusher | Managed, easy setup | 200k messages/day | Quick implementation |
| Ably | Enterprise-grade, reliable | 6M messages/month | Large-scale services |
| Supabase Realtime | Auto-detects DB changes | Included with Supabase | When using Supabase |
| Convex | DB + real-time integrated | Generous free tier | All-in-one solution |
| Liveblocks | Collaboration-focused | Free tier available | Figma-like collaboration apps |
| PartyKit | Cloudflare-based, edge | Free tier available | Global low-latency |
Pusher
A managed service for real-time features without running your own server.
Implement real-time messaging with Pusher.
Use pusher-js client and pusher server packages.
Channel: private-user-{userId}
Event: message// Pusher client example
import Pusher from 'pusher-js';
const pusher = new Pusher(process.env.NEXT_PUBLIC_PUSHER_KEY!, {
cluster: 'ap3',
});
const channel = pusher.subscribe(`private-user-${userId}`);
channel.bind('message', (data) => {
console.log('New message:', data);
});Ably
Provides enterprise-grade reliability.
Implement real-time messaging with Ably.
Use ably package.Supabase Realtime
If you're using Supabase, you can use real-time features without additional setup.
Subscribe to messages table changes with Supabase Realtime.
Display in UI when new message is added.// Supabase Realtime example
const channel = supabase
.channel('messages')
.on(
'postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'messages' },
(payload) => {
console.log('New message:', payload.new);
}
)
.subscribe();Convex
A platform with database and real-time fully integrated.
Create real-time message system with Convex.
Auto real-time updates with useQuery.PartyKit
Runs on Cloudflare edge, fast from anywhere in the world.
Create real-time server with PartyKit.
Include message broadcast feature.Which One Should You Choose?
| Situation | Recommendation |
|---|---|
| Full control, customization needed | Socket.io |
| Quick implementation, don't want server management | Pusher |
| Already using Supabase | Supabase Realtime |
| DB + real-time all-in-one | Convex |
| Global service, low-latency essential | PartyKit or Ably |
| Figma-like collaboration app | Liveblocks |
Why LinkHub Uses Supabase Realtime
LinkHub already uses Supabase for its database and authentication, so Realtime can be used immediately without additional setup. All real-time features like link editing and click tracking are handled through DB change detection, so no separate WebSocket server is needed.
Chapter Summary
- Understood the differences between HTTP, WebSocket, and SSE
- Used Supabase Realtime to reflect profile changes in real-time
- Built a click tracking system
- Created an analytics dashboard
- Implemented a live preview feature
- Made it installable like an app with PWA
- Learned about various real-time options
In the next chapter, we'll strengthen security.