Build a Custom Analytics Dashboard
Learn how to build a production-ready analytics dashboard that visualizes link performance with charts, filters, and real-time updates.
What You'll Build
By the end of this tutorial, you'll have a fully functional analytics dashboard with:
- Click statistics - Total clicks, unique visitors, click-through rate
- Time-series charts - Visualize clicks over time with Chart.js
- Geographic breakdown - See where your clicks are coming from
- Referrer analysis - Track which sources drive the most traffic
- Device & browser stats - Understand your audience's tech stack
- Date range filtering - Filter analytics by custom date ranges
- Real-time updates - Live data refresh every 30 seconds
- Export to CSV - Download analytics data for reporting
Prerequisites
- A SnipLink account with Pro plan or higher (sign up here)
- An API key with
analytics:readandlinks:readscopes (learn how) - Your workspace ID (found in dashboard URL)
- Basic knowledge of React, Vue, or vanilla JavaScript
- Familiarity with HTML/CSS
Understanding the Analytics API
The SnipLink Analytics API provides comprehensive link performance data in a single endpoint:
GET /v1/workspaces/{workspaceId}/links/{linkId}/analytics
Returns all analytics data in a single response, including:
The endpoint supports a days query parameter to specify the number of days to include (1-365, default: 30).
Step 1: Set Up Your Project
React + Next.js Setup
npx create-next-app@latest analytics-dashboard
cd analytics-dashboard
npm install chart.js react-chartjs-2 date-fnsCreate a .env.local file:
NEXT_PUBLIC_SNIPLINK_API_KEY=snip_live_your_api_key_here
NEXT_PUBLIC_SNIPLINK_WORKSPACE_ID=ws_xxxxxxxxxxxxxNEXT_PUBLIC_. For this tutorial, we're using client-side calls for simplicity.Vue 3 Setup
npm create vue@latest analytics-dashboard
cd analytics-dashboard
npm install chart.js vue-chartjs date-fnsCreate a .env file:
VITE_SNIPLINK_API_KEY=snip_live_your_api_key_here
VITE_SNIPLINK_WORKSPACE_ID=ws_xxxxxxxxxxxxxVanilla JavaScript Setup
mkdir analytics-dashboard
cd analytics-dashboard
npm init -y
npm install chart.js date-fnsCreate config.js:
export const CONFIG = {
apiKey: 039;snip_live_your_api_key_here039;,
workspaceId: 039;ws_xxxxxxxxxxxxx039;,
apiBase: 039;https://api.sniplink.co/v1',
};Step 2: Create the API Client
Build a reusable API client to handle all analytics requests with error handling and caching.
React/Next.js API Client
// lib/sniplink.ts
const API_BASE = 039;https://api.sniplink.co/v1';
const API_KEY = process.env.SNIPLINK_API_KEY!; // Server-side only
// Link-level analytics response
interface LinkAnalyticsResponse {
totalClicks: number;
regularClicks: number;
qrScans: number;
qrScanPercentage: number;
dailyStats: Array<{ date: string; clicks: number; qrScans: number }>;
topCountries: Array<{ country: string; clicks: number }>;
topReferrers: Array<{ referrer: string; clicks: number }>;
}
// Workspace-level analytics response
interface WorkspaceAnalyticsResponse {
summary: {
totalClicks: number;
regularClicks: number;
qrScans: number;
qrScanPercentage: number;
uniqueVisitors: number;
};
dailyStats: Array<{ date: string; clicks: number; qrScans: number }>;
topLinks: Array<{ linkId: string; shortUrl: string; clicks: number }>;
geographic: Array<{ country: string; clicks: number }>;
devices: Array<{ name: string; count: number }>;
browsers: Array<{ name: string; count: number }>;
referrers: Array<{ name: string; count: number }>;
}
interface FetchOptions {
days?: number; // 1-365, default 30
}
class SnipLinkAPI {
private cache = new Map<string, { data: any; timestamp: number }>();
private cacheTTL = 60000; // 1 minute cache
async getLinkAnalytics(
workspaceId: string,
linkId: string,
options?: FetchOptions
): Promise<LinkAnalyticsResponse> {
const cacheKey = `analytics-${linkId}-${options?.days || 30}`;
const cached = this.cache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < this.cacheTTL) {
return cached.data;
}
const url = new URL(
`${API_BASE}/workspaces/${workspaceId}/links/${linkId}/analytics`
);
if (options?.days) url.searchParams.set(039;days039;, String(options.days));
const response = await fetch(url.toString(), {
headers: {
039;X-API-Key039;: API_KEY,
039;Content-Type039;: 039;application/json039;,
},
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error?.message || 039;API request failed039;);
}
const data = await response.json();
this.cache.set(cacheKey, { data, timestamp: Date.now() });
return data;
}
async getWorkspaceAnalytics(
workspaceId: string,
options?: FetchOptions
): Promise<WorkspaceAnalyticsResponse> {
const cacheKey = `workspace-analytics-${workspaceId}-${options?.days || 30}`;
const cached = this.cache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < this.cacheTTL) {
return cached.data;
}
const url = new URL(`${API_BASE}/workspaces/${workspaceId}/analytics`);
if (options?.days) url.searchParams.set(039;days039;, String(options.days));
const response = await fetch(url.toString(), {
headers: {
039;X-API-Key039;: API_KEY,
039;Content-Type039;: 039;application/json039;,
},
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error?.message || 039;API request failed039;);
}
const data = await response.json();
this.cache.set(cacheKey, { data, timestamp: Date.now() });
return data;
}
async getLinks(workspaceId: string) {
// Returns { links: Link[], pagination: { ... } }
const response = await fetch(`${API_BASE}/workspaces/${workspaceId}/links`, {
headers: {
039;X-API-Key039;: API_KEY,
039;Content-Type039;: 039;application/json039;,
},
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error?.message || 039;Failed to fetch links039;);
}
return response.json();
}
clearCache() {
this.cache.clear();
}
}
export const api = new SnipLinkAPI();Vue 3 API Client
// services/sniplink.ts
const API_BASE = 039;https://api.sniplink.co/v1';
const API_KEY = import.meta.env.VITE_SNIPLINK_API_KEY;
interface FetchOptions {
startDate?: string;
endDate?: string;
}
class SnipLinkAPI {
private cache = new Map<string, { data: any; timestamp: number }>();
private cacheTTL = 60000;
private async fetch<T>(endpoint: string, options?: FetchOptions): Promise<T> {
const cacheKey = `${endpoint}?${JSON.stringify(options || {})}`;
const cached = this.cache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < this.cacheTTL) {
return cached.data;
}
const url = new URL(`${API_BASE}${endpoint}`);
if (options?.startDate) url.searchParams.set(039;startDate039;, options.startDate);
if (options?.endDate) url.searchParams.set(039;endDate039;, options.endDate);
const response = await fetch(url.toString(), {
headers: {
039;X-API-Key039;: API_KEY,
039;Content-Type039;: 039;application/json039;,
},
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 039;API request failed039;);
}
const data = await response.json();
this.cache.set(cacheKey, { data, timestamp: Date.now() });
return data;
}
async getOverallAnalytics(linkId: string, options?: FetchOptions) {
return this.fetch(`/links/${linkId}/analytics`, options);
}
async getTimeseries(linkId: string, options?: FetchOptions & { interval?: 039;1h039; | 039;1d039; | 039;1w039; }) {
return this.fetch(`/links/${linkId}/analytics/timeseries`, options);
}
async getCountries(linkId: string, options?: FetchOptions) {
return this.fetch(`/links/${linkId}/analytics/countries`, options);
}
async getReferrers(linkId: string, options?: FetchOptions) {
return this.fetch(`/links/${linkId}/analytics/referrers`, options);
}
async getDevices(linkId: string, options?: FetchOptions) {
return this.fetch(`/links/${linkId}/analytics/devices`, options);
}
async getLinks(workspaceId: string) {
return this.fetch(`/workspaces/${workspaceId}/links`);
}
clearCache() {
this.cache.clear();
}
}
export const api = new SnipLinkAPI();Vanilla JavaScript API Client
// api.js
import { CONFIG } from 039;./config.js039;;
class SnipLinkAPI {
constructor() {
this.cache = new Map();
this.cacheTTL = 60000; // 1 minute
}
async fetch(endpoint, options = {}) {
const cacheKey = `${endpoint}?${JSON.stringify(options)}`;
const cached = this.cache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < this.cacheTTL) {
return cached.data;
}
const url = new URL(`${CONFIG.apiBase}${endpoint}`);
if (options.startDate) url.searchParams.set(039;startDate039;, options.startDate);
if (options.endDate) url.searchParams.set(039;endDate039;, options.endDate);
if (options.interval) url.searchParams.set(039;interval039;, options.interval);
const response = await fetch(url.toString(), {
headers: {
039;X-API-Key039;: CONFIG.apiKey,
039;Content-Type039;: 039;application/json039;,
},
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 039;API request failed039;);
}
const data = await response.json();
this.cache.set(cacheKey, { data, timestamp: Date.now() });
return data;
}
async getOverallAnalytics(linkId, options) {
return this.fetch(`/links/${linkId}/analytics`, options);
}
async getTimeseries(linkId, options) {
return this.fetch(`/links/${linkId}/analytics/timeseries`, options);
}
async getCountries(linkId, options) {
return this.fetch(`/links/${linkId}/analytics/countries`, options);
}
async getReferrers(linkId, options) {
return this.fetch(`/links/${linkId}/analytics/referrers`, options);
}
async getDevices(linkId, options) {
return this.fetch(`/links/${linkId}/analytics/devices`, options);
}
async getLinks(workspaceId) {
return this.fetch(`/workspaces/${workspaceId}/links`);
}
clearCache() {
this.cache.clear();
}
}
export const api = new SnipLinkAPI();Step 3: Build Chart Components
Create reusable chart components using Chart.js.
React Line Chart (Clicks Over Time)
// components/ClicksChart.tsx
039;use client039;;
import { Line } from 039;react-chartjs-2039;;
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler,
} from 039;chart.js039;;
import { format } from 039;date-fns039;;
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler
);
interface TimeseriesData {
timestamp: string;
clicks: number;
uniqueClicks: number;
}
interface ClicksChartProps {
data: TimeseriesData[];
}
export function ClicksChart({ data }: ClicksChartProps) {
const chartData = {
labels: data.map((d) => format(new Date(d.timestamp), 039;MMM d, h:mm a039;)),
datasets: [
{
label: 039;Total Clicks039;,
data: data.map((d) => d.clicks),
borderColor: 039;rgb(16, 185, 129)039;,
backgroundColor: 039;rgba(16, 185, 129, 0.1)039;,
fill: true,
tension: 0.4,
},
{
label: 039;Unique Clicks039;,
data: data.map((d) => d.uniqueClicks),
borderColor: 039;rgb(59, 130, 246)039;,
backgroundColor: 039;rgba(59, 130, 246, 0.1)039;,
fill: true,
tension: 0.4,
},
],
};
const options = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 039;top039; as const,
},
title: {
display: true,
text: 039;Clicks Over Time039;,
},
},
scales: {
y: {
beginAtZero: true,
ticks: {
precision: 0,
},
},
},
};
return (
<div style={{ height: 039;400px039; }}>
<Line data={chartData} options={options} />
</div>
);
}React Pie Chart (Geographic Distribution)
// components/CountryChart.tsx
039;use client039;;
import { Pie } from 039;react-chartjs-2039;;
import { Chart as ChartJS, ArcElement, Tooltip, Legend } from 039;chart.js039;;
ChartJS.register(ArcElement, Tooltip, Legend);
interface CountryData {
country: string;
clicks: number;
}
interface CountryChartProps {
data: CountryData[];
}
export function CountryChart({ data }: CountryChartProps) {
// Take top 10 countries, group the rest as "Other"
const topCountries = data.slice(0, 10);
const otherClicks = data.slice(10).reduce((sum, c) => sum + c.clicks, 0);
if (otherClicks > 0) {
topCountries.push({ country: 039;Other039;, clicks: otherClicks });
}
const chartData = {
labels: topCountries.map((c) => c.country),
datasets: [
{
label: 039;Clicks by Country039;,
data: topCountries.map((c) => c.clicks),
backgroundColor: [
039;rgba(16, 185, 129, 0.8)039;,
039;rgba(59, 130, 246, 0.8)039;,
039;rgba(251, 191, 36, 0.8)039;,
039;rgba(239, 68, 68, 0.8)039;,
039;rgba(168, 85, 247, 0.8)039;,
039;rgba(236, 72, 153, 0.8)039;,
039;rgba(14, 165, 233, 0.8)039;,
039;rgba(245, 158, 11, 0.8)039;,
039;rgba(34, 197, 94, 0.8)039;,
039;rgba(99, 102, 241, 0.8)039;,
039;rgba(156, 163, 175, 0.8)039;,
],
borderWidth: 2,
borderColor: 039;#fff039;,
},
],
};
const options = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 039;right039; as const,
},
title: {
display: true,
text: 039;Clicks by Country039;,
},
},
};
return (
<div style={{ height: 039;400px039; }}>
<Pie data={chartData} options={options} />
</div>
);
}React Bar Chart (Referrer Sources)
// components/ReferrerChart.tsx
039;use client039;;
import { Bar } from 039;react-chartjs-2039;;
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend,
} from 039;chart.js039;;
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend);
interface ReferrerData {
referrer: string;
clicks: number;
}
interface ReferrerChartProps {
data: ReferrerData[];
}
export function ReferrerChart({ data }: ReferrerChartProps) {
const chartData = {
labels: data.map((r) => r.referrer || 039;Direct039;),
datasets: [
{
label: 039;Clicks039;,
data: data.map((r) => r.clicks),
backgroundColor: 039;rgba(16, 185, 129, 0.8)039;,
borderColor: 039;rgb(16, 185, 129)039;,
borderWidth: 1,
},
],
};
const options = {
responsive: true,
maintainAspectRatio: false,
indexAxis: 039;y039; as const,
plugins: {
legend: {
display: false,
},
title: {
display: true,
text: 039;Top Referrers039;,
},
},
scales: {
x: {
beginAtZero: true,
ticks: {
precision: 0,
},
},
},
};
return (
<div style={{ height: 039;400px039; }}>
<Bar data={chartData} options={options} />
</div>
);
}Step 4: Build the Dashboard Page
Now let's assemble everything into a complete dashboard with filtering.
React/Next.js Dashboard
// app/dashboard/page.tsx
039;use client039;;
import { useState, useEffect } from 039;react039;;
import { api } from 039;@/lib/sniplink039;;
import { ClicksChart } from 039;@/components/ClicksChart039;;
import { CountryChart } from 039;@/components/CountryChart039;;
import { ReferrerChart } from 039;@/components/ReferrerChart039;;
import { subDays, format } from 039;date-fns039;;
const WORKSPACE_ID = process.env.NEXT_PUBLIC_SNIPLINK_WORKSPACE_ID!;
export default function DashboardPage() {
const [links, setLinks] = useState<any[]>([]);
const [selectedLink, setSelectedLink] = useState<string>(039;039;);
const [startDate, setStartDate] = useState(format(subDays(new Date(), 30), 039;yyyy-MM-dd039;));
const [endDate, setEndDate] = useState(format(new Date(), 039;yyyy-MM-dd039;));
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [overallData, setOverallData] = useState<any>(null);
const [timeseriesData, setTimeseriesData] = useState<any[]>([]);
const [countryData, setCountryData] = useState<any[]>([]);
const [referrerData, setReferrerData] = useState<any[]>([]);
const [deviceData, setDeviceData] = useState<any[]>([]);
// Load links on mount
useEffect(() => {
async function loadLinks() {
try {
const data = await api.getLinks(WORKSPACE_ID);
setLinks(data.links || []);
if (data.links?.length > 0) {
setSelectedLink(data.links[0].id);
}
setLoading(false);
} catch (err) {
setError(err instanceof Error ? err.message : 039;Failed to load links039;);
setLoading(false);
}
}
loadLinks();
}, []);
// Load analytics when link or date range changes
useEffect(() => {
if (!selectedLink) return;
async function loadAnalytics() {
setLoading(true);
try {
const options = {
startDate: new Date(startDate).toISOString(),
endDate: new Date(endDate).toISOString(),
};
const [overall, timeseries, countries, referrers, devices] = await Promise.all([
api.getOverallAnalytics(selectedLink, options),
api.getTimeseries(selectedLink, { ...options, interval: 039;1d039; }),
api.getCountries(selectedLink, options),
api.getReferrers(selectedLink, options),
api.getDevices(selectedLink, options),
]);
setOverallData(overall);
setTimeseriesData(timeseries.data || []);
setCountryData(countries.data || []);
setReferrerData(referrers.data || []);
setDeviceData(devices.data || []);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : 039;Failed to load analytics039;);
} finally {
setLoading(false);
}
}
loadAnalytics();
// Set up auto-refresh every 30 seconds
const interval = setInterval(() => {
api.clearCache();
loadAnalytics();
}, 30000);
return () => clearInterval(interval);
}, [selectedLink, startDate, endDate]);
if (loading && !overallData) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-gray-600">Loading...</div>
</div>
);
}
if (error) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="rounded-lg border border-red-200 bg-red-50 p-6 text-red-800">
<h2 className="mb-2 text-lg font-semibold">Error</h2>
<p>{error}</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 p-8">
<div className="mx-auto max-w-7xl">
<h1 className="mb-8 text-3xl font-bold text-gray-900">Analytics Dashboard</h1>
{/* Filters */}
<div className="mb-8 grid gap-4 md:grid-cols-3">
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">Link</label>
<select
value={selectedLink}
onChange={(e) => setSelectedLink(e.target.value)}
className="w-full rounded-lg border border-gray-300 px-4 py-2"
>
{links.map((link) => (
<option key={link.id} value={link.id}>
{link.shortUrl}
</option>
))}
</select>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">Start Date</label>
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="w-full rounded-lg border border-gray-300 px-4 py-2"
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">End Date</label>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="w-full rounded-lg border border-gray-300 px-4 py-2"
/>
</div>
</div>
{/* Stats Cards */}
{overallData && (
<div className="mb-8 grid gap-6 md:grid-cols-4">
<StatCard title="Total Clicks" value={overallData.clicks || 0} />
<StatCard title="Unique Visitors" value={overallData.uniqueClicks || 0} />
<StatCard
title="Click-Through Rate"
value={`${((overallData.ctr || 0) * 100).toFixed(2)}%`}
/>
<StatCard title="Avg. Click Rate" value={`${overallData.avgClicksPerDay || 0}/day`} />
</div>
)}
{/* Charts */}
<div className="grid gap-8 md:grid-cols-2">
<div className="rounded-lg border border-gray-200 bg-white p-6">
<ClicksChart data={timeseriesData} />
</div>
<div className="rounded-lg border border-gray-200 bg-white p-6">
<CountryChart data={countryData} />
</div>
<div className="rounded-lg border border-gray-200 bg-white p-6">
<ReferrerChart data={referrerData.slice(0, 10)} />
</div>
<div className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Device Breakdown</h3>
<div className="space-y-3">
{deviceData.map((device: any) => (
<div key={device.device} className="flex items-center justify-between">
<span className="capitalize text-gray-700">{device.device}</span>
<div className="flex items-center gap-3">
<div className="h-2 w-32 overflow-hidden rounded-full bg-gray-200">
<div
className="h-full bg-green-500"
style={{
width: `${(device.clicks / overallData.clicks) * 100}%`,
}}
/>
</div>
<span className="w-16 text-right text-sm font-medium text-gray-900">
{device.clicks}
</span>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
);
}
function StatCard({ title, value }: { title: string; value: string | number }) {
return (
<div className="rounded-lg border border-gray-200 bg-white p-6">
<div className="text-sm font-medium text-gray-600">{title}</div>
<div className="mt-2 text-3xl font-bold text-gray-900">{value}</div>
</div>
);
}Step 5: Add Export Functionality
Allow users to download analytics data as CSV for reporting.
// utils/export.ts
export function exportToCSV(data: any[], filename: string) {
if (data.length === 0) return;
// Get all keys from the first object
const headers = Object.keys(data[0]);
// Create CSV content
const csvContent = [
headers.join(039;,039;), // Header row
...data.map((row) =>
headers.map((header) => {
const value = row[header];
// Escape quotes and wrap in quotes if contains comma
const stringValue = String(value || 039;039;);
return stringValue.includes(039;,039;) ? `"${stringValue.replace(/"/g, '""')}"` : stringValue;
}).join(039;,039;)
),
].join(039;\n039;);
// Create blob and download
const blob = new Blob([csvContent], { type: 039;text/csv;charset=utf-8;039; });
const link = document.createElement(039;a039;);
link.href = URL.createObjectURL(blob);
link.download = `${filename}-${new Date().toISOString().split('T')[0]}.csv`;
link.click();
URL.revokeObjectURL(link.href);
}
// Usage in dashboard:
// <button onClick={() => exportToCSV(timeseriesData, 'analytics')}>
// Export to CSV
// </button>Step 6: Add Real-Time Updates with Webhooks
For truly real-time analytics, use webhooks instead of polling. Subscribe to link.clicked events.
// pages/api/webhooks/clicks.ts (Next.js API route)
import { NextApiRequest, NextApiResponse } from 039;next039;;
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 039;POST039;) {
return res.status(405).json({ error: 039;Method not allowed039; });
}
// Verify webhook signature (see Webhooks guide)
const signature = req.headers[039;x-sniplink-signature039;] as string;
// ... verify signature logic here
const event = req.body;
if (event.type === 039;link.clicked039;) {
// Broadcast to connected clients via WebSocket or Server-Sent Events
// Or update a shared state/database that the dashboard polls
console.log(039;New click:039;, event.data);
// Example: Broadcast via Pusher, Ably, or custom WebSocket
// pusher.trigger('analytics', 'new-click', event.data);
}
res.status(200).json({ received: true });
}
// In your dashboard component, listen for real-time updates:
// useEffect(() => {
// const pusher = new Pusher('YOUR_PUSHER_KEY', { cluster: 'us2' });
// const channel = pusher.subscribe('analytics');
//
// channel.bind('new-click', (data: any) => {
// // Update charts in real-time
// setOverallData(prev => ({ ...prev, clicks: prev.clicks + 1 }));
// });
//
// return () => {
// channel.unbind_all();
// channel.unsubscribe();
// };
// }, []);link.clicked events.Production Optimizations
1. Server-Side Rendering (Next.js)
Fetch initial data on the server to improve performance:
// app/dashboard/page.tsx
export async function generateStaticParams() {
// Pre-render dashboard for specific links
return [];
}
async function getInitialData(linkId: string, workspaceId: string) {
// Fetch on server side
const res = await fetch(`https://api.sniplink.co/v1/workspaces/${workspaceId}/links/${linkId}/analytics`, {
headers: { 039;X-API-Key039;: process.env.SNIPLINK_API_KEY! },
});
return res.json();
}2. Caching Strategy
Implement intelligent caching to reduce API calls:
// lib/cache.ts
const CACHE_DURATIONS = {
overall: 60000, // 1 minute
timeseries: 300000, // 5 minutes
countries: 600000, // 10 minutes
referrers: 600000, // 10 minutes
devices: 600000, // 10 minutes
};
// Use different cache TTLs based on data type
// Overall stats change frequently โ short cache
// Geographic/device data changes slowly โ long cache3. Loading States
Show skeleton loaders during data fetches:
function SkeletonCard() {
return (
<div className="animate-pulse rounded-lg border border-gray-200 bg-white p-6">
<div className="h-4 w-24 rounded bg-gray-200"></div>
<div className="mt-2 h-8 w-32 rounded bg-gray-300"></div>
</div>
);
}4. Error Boundaries
Gracefully handle chart rendering errors:
// components/ErrorBoundary.tsx
039;use client039;;
import { Component, ReactNode } from 039;react039;;
interface Props {
children: ReactNode;
}
interface State {
hasError: boolean;
}
export class ChartErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
return { hasError: true };
}
render() {
if (this.state.hasError) {
return (
<div className="rounded-lg border border-red-200 bg-red-50 p-6">
<p className="text-red-800">Failed to render chart</p>
</div>
);
}
return this.props.children;
}
}
// Wrap charts:
// <ChartErrorBoundary>
// <ClicksChart data={timeseriesData} />
// </ChartErrorBoundary>Testing Your Dashboard
1. Create Test Data
Generate some clicks to populate your analytics:
# Click your short link multiple times from different devices/browsers
# Use VPN or proxy to simulate different countries
# Share link on social media to generate referrer data2. Test Date Filtering
Verify that date range filters work correctly:
- Last 7 days
- Last 30 days
- Custom date range
- Future dates (should return empty data)
3. Test Edge Cases
- Link with zero clicks
- Invalid link ID
- Network errors (disconnect WiFi)
- Rate limit exceeded (make 100+ requests quickly)
Next Steps
Enhance your analytics dashboard with:
- Comparison mode: Compare metrics between different time periods
- Alerts: Get notified when clicks exceed thresholds
- Multi-link view: Compare performance across multiple links
- Custom metrics: Calculate conversion rates, engagement scores
- Share reports: Generate shareable analytics reports
- Dark mode: Add theme toggle for better UX
Need Help?
Having trouble with this tutorial? We're here to help.