챕터
4. 실시간 기능

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:

FeatureDescriptionMethod
Profile EditingChanges instantly reflected on public pageSupabase Realtime
Click TrackingRecord when visitors click linksDB INSERT + Realtime
Analytics DashboardDisplay click stats in real-timeSupabase Subscription
Live PreviewUpdate preview panel while editingReact 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 occur

Real-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 saving

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

ServiceFeaturesFree TierRecommended For
Socket.ioMost popular, requires own serverFree (self-hosted)Full control needed
PusherManaged, easy setup200k messages/dayQuick implementation
AblyEnterprise-grade, reliable6M messages/monthLarge-scale services
Supabase RealtimeAuto-detects DB changesIncluded with SupabaseWhen using Supabase
ConvexDB + real-time integratedGenerous free tierAll-in-one solution
LiveblocksCollaboration-focusedFree tier availableFigma-like collaboration apps
PartyKitCloudflare-based, edgeFree tier availableGlobal 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?

SituationRecommendation
Full control, customization neededSocket.io
Quick implementation, don't want server managementPusher
Already using SupabaseSupabase Realtime
DB + real-time all-in-oneConvex
Global service, low-latency essentialPartyKit or Ably
Figma-like collaboration appLiveblocks
💡

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.

Chapter 5: Security →