How to Build a Social Media Dashboard with Next.js (Complete Tutorial)
Every social media manager, agency, and brand needs a dashboard.
But here's the problem:
- Sprout Social: $249/user/month
- Hootsuite: $99-739/month
- Buffer Analyze: $120/month
For a small agency with 3 team members tracking 10 clients, you're looking at $750-2,000+/month just for analytics.
What if you could build your own?
In this tutorial, we'll create a full-featured social media dashboard using Next.js 14, Tailwind CSS, and real data from the SociaVault API. By the end, you'll have:
- Multi-platform profile tracking (TikTok, Instagram, YouTube)
- Real-time metrics and engagement rates
- Historical growth charts
- Competitor comparison views
- Export functionality
Let's build it.
Want the data layer? See how to build an influencer database for the backend.
What We're Building
A dashboard that:
- Tracks multiple social media profiles across platforms
- Displays key metrics (followers, engagement, views)
- Shows growth trends over time
- Compares performance across accounts
- Looks professional (clients will see this)
Tech Stack:
- Next.js 14 (App Router)
- Tailwind CSS + shadcn/ui
- Recharts for graphs
- SQLite for data storage
- SociaVault API for social data
Project Setup
1. Create Next.js Project
npx create-next-app@latest social-dashboard
cd social-dashboard
Choose these options:
- TypeScript: Yes
- ESLint: Yes
- Tailwind CSS: Yes
- App Router: Yes
2. Install Dependencies
npm install recharts better-sqlite3 axios date-fns
npm install -D @types/better-sqlite3
3. Add shadcn/ui Components
npx shadcn-ui@latest init
npx shadcn-ui@latest add card button input tabs table badge
4. Environment Variables
Create .env.local:
SOCIAVAULT_API_KEY=your_api_key_here
Database Setup
Create lib/db.ts:
import Database from 'better-sqlite3';
import path from 'path';
const db = new Database(path.join(process.cwd(), 'dashboard.db'));
// Initialize tables
db.exec(`
CREATE TABLE IF NOT EXISTS profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
platform TEXT NOT NULL,
username TEXT NOT NULL,
display_name TEXT,
profile_image TEXT,
followers INTEGER DEFAULT 0,
following INTEGER DEFAULT 0,
posts_count INTEGER DEFAULT 0,
engagement_rate REAL DEFAULT 0,
verified INTEGER DEFAULT 0,
last_updated TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
UNIQUE(platform, username)
);
CREATE TABLE IF NOT EXISTS metrics_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
profile_id INTEGER,
followers INTEGER,
engagement_rate REAL,
recorded_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (profile_id) REFERENCES profiles(id)
);
CREATE INDEX IF NOT EXISTS idx_metrics_profile ON metrics_history(profile_id);
CREATE INDEX IF NOT EXISTS idx_metrics_date ON metrics_history(recorded_at);
`);
export default db;
API Routes
Fetch Social Data
Create app/api/profiles/route.ts:
import { NextRequest, NextResponse } from 'next/server';
import db from '@/lib/db';
const API_BASE = 'https://api.sociavault.com/v1/scrape';
const API_KEY = process.env.SOCIAVAULT_API_KEY;
async function fetchTikTokProfile(username: string) {
const response = await fetch(
`${API_BASE}/tiktok/profile?username=${username}`,
{ headers: { 'Authorization': `Bearer ${API_KEY}` } }
);
const data = await response.json();
const profile = data.data;
return {
platform: 'tiktok',
username: profile.uniqueId,
display_name: profile.nickname,
profile_image: profile.avatarLarger,
followers: profile.followerCount || profile.stats?.followerCount || 0,
following: profile.followingCount || profile.stats?.followingCount || 0,
posts_count: profile.videoCount || profile.stats?.videoCount || 0,
verified: profile.verified ? 1 : 0
};
}
async function fetchInstagramProfile(username: string) {
const response = await fetch(
`${API_BASE}/instagram/profile?username=${username}`,
{ headers: { 'Authorization': `Bearer ${API_KEY}` } }
);
const data = await response.json();
const profile = data.data;
return {
platform: 'instagram',
username: profile.username,
display_name: profile.full_name,
profile_image: profile.profile_pic_url,
followers: profile.follower_count || profile.followers || 0,
following: profile.following_count || profile.following || 0,
posts_count: profile.media_count || profile.posts || 0,
verified: profile.is_verified ? 1 : 0
};
}
async function fetchYouTubeChannel(channelUrl: string) {
const response = await fetch(
`${API_BASE}/youtube/channel?url=${encodeURIComponent(channelUrl)}`,
{ headers: { 'Authorization': `Bearer ${API_KEY}` } }
);
const data = await response.json();
const channel = data.data;
return {
platform: 'youtube',
username: channel.customUrl || channel.handle || channel.channelId,
display_name: channel.title || channel.name,
profile_image: channel.thumbnail || channel.avatar,
followers: channel.subscriberCount || channel.subscribers || 0,
following: 0,
posts_count: channel.videoCount || 0,
verified: channel.isVerified ? 1 : 0
};
}
// GET - Fetch all profiles
export async function GET() {
const profiles = db.prepare('SELECT * FROM profiles ORDER BY followers DESC').all();
return NextResponse.json(profiles);
}
// POST - Add new profile
export async function POST(request: NextRequest) {
const { platform, username } = await request.json();
try {
let profileData;
switch (platform) {
case 'tiktok':
profileData = await fetchTikTokProfile(username);
break;
case 'instagram':
profileData = await fetchInstagramProfile(username);
break;
case 'youtube':
profileData = await fetchYouTubeChannel(username);
break;
default:
return NextResponse.json({ error: 'Invalid platform' }, { status: 400 });
}
// Upsert profile
const stmt = db.prepare(`
INSERT INTO profiles (platform, username, display_name, profile_image, followers, following, posts_count, verified, last_updated)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(platform, username) DO UPDATE SET
display_name = excluded.display_name,
profile_image = excluded.profile_image,
followers = excluded.followers,
following = excluded.following,
posts_count = excluded.posts_count,
verified = excluded.verified,
last_updated = excluded.last_updated
`);
stmt.run(
profileData.platform,
profileData.username,
profileData.display_name,
profileData.profile_image,
profileData.followers,
profileData.following,
profileData.posts_count,
profileData.verified,
new Date().toISOString()
);
// Get the profile ID and save to history
const profile = db.prepare(
'SELECT id FROM profiles WHERE platform = ? AND username = ?'
).get(profileData.platform, profileData.username) as { id: number };
db.prepare(
'INSERT INTO metrics_history (profile_id, followers, engagement_rate) VALUES (?, ?, ?)'
).run(profile.id, profileData.followers, 0);
return NextResponse.json({ success: true, data: profileData });
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
Get Growth History
Create app/api/profiles/[id]/history/route.ts:
import { NextRequest, NextResponse } from 'next/server';
import db from '@/lib/db';
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const history = db.prepare(`
SELECT followers, engagement_rate, recorded_at
FROM metrics_history
WHERE profile_id = ?
ORDER BY recorded_at ASC
LIMIT 30
`).all(params.id);
return NextResponse.json(history);
}
Refresh All Profiles
Create app/api/profiles/refresh/route.ts:
import { NextResponse } from 'next/server';
import db from '@/lib/db';
const API_BASE = 'https://api.sociavault.com/v1/scrape';
const API_KEY = process.env.SOCIAVAULT_API_KEY;
export async function POST() {
const profiles = db.prepare('SELECT * FROM profiles').all() as any[];
const results = [];
for (const profile of profiles) {
try {
let endpoint = '';
let params = '';
switch (profile.platform) {
case 'tiktok':
endpoint = 'tiktok/profile';
params = `username=${profile.username}`;
break;
case 'instagram':
endpoint = 'instagram/profile';
params = `username=${profile.username}`;
break;
case 'youtube':
endpoint = 'youtube/channel';
params = `url=https://youtube.com/@${profile.username}`;
break;
}
const response = await fetch(
`${API_BASE}/${endpoint}?${params}`,
{ headers: { 'Authorization': `Bearer ${API_KEY}` } }
);
const data = await response.json();
let followers = 0;
if (profile.platform === 'tiktok') {
followers = data.data.followerCount || data.data.stats?.followerCount || 0;
} else if (profile.platform === 'instagram') {
followers = data.data.follower_count || data.data.followers || 0;
} else if (profile.platform === 'youtube') {
followers = data.data.subscriberCount || data.data.subscribers || 0;
}
// Update profile
db.prepare(
'UPDATE profiles SET followers = ?, last_updated = ? WHERE id = ?'
).run(followers, new Date().toISOString(), profile.id);
// Add to history
db.prepare(
'INSERT INTO metrics_history (profile_id, followers, engagement_rate) VALUES (?, ?, ?)'
).run(profile.id, followers, 0);
results.push({ username: profile.username, success: true });
// Rate limiting
await new Promise(r => setTimeout(r, 300));
} catch (error: any) {
results.push({ username: profile.username, success: false, error: error.message });
}
}
return NextResponse.json({ results });
}
Dashboard Components
Stats Cards
Create components/StatsCard.tsx:
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
interface StatsCardProps {
title: string;
value: string | number;
change?: string;
icon?: React.ReactNode;
}
export function StatsCard({ title, value, change, icon }: StatsCardProps) {
const isPositive = change?.startsWith('+');
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{title}
</CardTitle>
{icon}
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{value}</div>
{change && (
<p className={`text-xs ${isPositive ? 'text-green-500' : 'text-red-500'}`}>
{change} from last week
</p>
)}
</CardContent>
</Card>
);
}
Profile Table
Create components/ProfileTable.tsx:
'use client';
import { Badge } from '@/components/ui/badge';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
interface Profile {
id: number;
platform: string;
username: string;
display_name: string;
profile_image: string;
followers: number;
posts_count: number;
verified: number;
last_updated: string;
}
interface ProfileTableProps {
profiles: Profile[];
onSelect: (profile: Profile) => void;
}
function formatNumber(num: number): string {
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
if (num >= 1000) return (num / 1000).toFixed(1) + 'K';
return num.toString();
}
function getPlatformColor(platform: string): string {
switch (platform) {
case 'tiktok': return 'bg-black text-white';
case 'instagram': return 'bg-gradient-to-r from-purple-500 to-pink-500 text-white';
case 'youtube': return 'bg-red-600 text-white';
default: return 'bg-gray-500 text-white';
}
}
export function ProfileTable({ profiles, onSelect }: ProfileTableProps) {
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Profile</TableHead>
<TableHead>Platform</TableHead>
<TableHead className="text-right">Followers</TableHead>
<TableHead className="text-right">Posts</TableHead>
<TableHead>Last Updated</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{profiles.map((profile) => (
<TableRow
key={profile.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => onSelect(profile)}
>
<TableCell className="flex items-center gap-3">
{profile.profile_image && (
<img
src={profile.profile_image}
alt={profile.username}
className="w-10 h-10 rounded-full object-cover"
/>
)}
<div>
<div className="font-medium flex items-center gap-2">
{profile.display_name}
{profile.verified === 1 && (
<span className="text-blue-500">✓</span>
)}
</div>
<div className="text-sm text-muted-foreground">
@{profile.username}
</div>
</div>
</TableCell>
<TableCell>
<Badge className={getPlatformColor(profile.platform)}>
{profile.platform}
</Badge>
</TableCell>
<TableCell className="text-right font-medium">
{formatNumber(profile.followers)}
</TableCell>
<TableCell className="text-right">
{formatNumber(profile.posts_count)}
</TableCell>
<TableCell className="text-muted-foreground">
{profile.last_updated
? new Date(profile.last_updated).toLocaleDateString()
: '-'
}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
Growth Chart
Create components/GrowthChart.tsx:
'use client';
import { useEffect, useState } from 'react';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { format } from 'date-fns';
interface GrowthChartProps {
profileId: number;
title: string;
}
export function GrowthChart({ profileId, title }: GrowthChartProps) {
const [data, setData] = useState<any[]>([]);
useEffect(() => {
fetch(`/api/profiles/${profileId}/history`)
.then(r => r.json())
.then(history => {
setData(history.map((h: any) => ({
date: format(new Date(h.recorded_at), 'MMM d'),
followers: h.followers
})));
});
}, [profileId]);
return (
<Card>
<CardHeader>
<CardTitle>{title}</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis />
<Tooltip />
<Line
type="monotone"
dataKey="followers"
stroke="#3b82f6"
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
);
}
Add Profile Form
Create components/AddProfileForm.tsx:
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
interface AddProfileFormProps {
onAdd: () => void;
}
export function AddProfileForm({ onAdd }: AddProfileFormProps) {
const [platform, setPlatform] = useState('tiktok');
const [username, setUsername] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
await fetch('/api/profiles', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ platform, username })
});
setUsername('');
onAdd();
} catch (error) {
console.error('Failed to add profile:', error);
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit} className="flex gap-3">
<Select value={platform} onValueChange={setPlatform}>
<SelectTrigger className="w-[150px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="tiktok">TikTok</SelectItem>
<SelectItem value="instagram">Instagram</SelectItem>
<SelectItem value="youtube">YouTube</SelectItem>
</SelectContent>
</Select>
<Input
placeholder="Username or URL"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="flex-1"
/>
<Button type="submit" disabled={loading || !username}>
{loading ? 'Adding...' : 'Add Profile'}
</Button>
</form>
);
}
Main Dashboard Page
Create app/page.tsx:
'use client';
import { useEffect, useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { StatsCard } from '@/components/StatsCard';
import { ProfileTable } from '@/components/ProfileTable';
import { GrowthChart } from '@/components/GrowthChart';
import { AddProfileForm } from '@/components/AddProfileForm';
interface Profile {
id: number;
platform: string;
username: string;
display_name: string;
profile_image: string;
followers: number;
posts_count: number;
verified: number;
last_updated: string;
}
function formatNumber(num: number): string {
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
if (num >= 1000) return (num / 1000).toFixed(1) + 'K';
return num.toString();
}
export default function Dashboard() {
const [profiles, setProfiles] = useState<Profile[]>([]);
const [selectedProfile, setSelectedProfile] = useState<Profile | null>(null);
const [refreshing, setRefreshing] = useState(false);
const loadProfiles = async () => {
const response = await fetch('/api/profiles');
const data = await response.json();
setProfiles(data);
};
const refreshAll = async () => {
setRefreshing(true);
await fetch('/api/profiles/refresh', { method: 'POST' });
await loadProfiles();
setRefreshing(false);
};
useEffect(() => {
loadProfiles();
}, []);
const totalFollowers = profiles.reduce((sum, p) => sum + p.followers, 0);
const tiktokProfiles = profiles.filter(p => p.platform === 'tiktok');
const instagramProfiles = profiles.filter(p => p.platform === 'instagram');
const youtubeProfiles = profiles.filter(p => p.platform === 'youtube');
return (
<div className="min-h-screen bg-background">
<div className="container mx-auto py-8 px-4">
{/* Header */}
<div className="flex justify-between items-center mb-8">
<div>
<h1 className="text-3xl font-bold">Social Media Dashboard</h1>
<p className="text-muted-foreground">Track your social media performance</p>
</div>
<Button onClick={refreshAll} disabled={refreshing}>
{refreshing ? 'Refreshing...' : '🔄 Refresh All'}
</Button>
</div>
{/* Stats Overview */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
<StatsCard
title="Total Followers"
value={formatNumber(totalFollowers)}
/>
<StatsCard
title="TikTok Profiles"
value={tiktokProfiles.length}
/>
<StatsCard
title="Instagram Profiles"
value={instagramProfiles.length}
/>
<StatsCard
title="YouTube Channels"
value={youtubeProfiles.length}
/>
</div>
{/* Add Profile */}
<Card className="mb-8">
<CardHeader>
<CardTitle>Add Profile</CardTitle>
</CardHeader>
<CardContent>
<AddProfileForm onAdd={loadProfiles} />
</CardContent>
</Card>
{/* Main Content */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Profile List */}
<div className="lg:col-span-2">
<Card>
<CardHeader>
<CardTitle>Tracked Profiles</CardTitle>
</CardHeader>
<CardContent>
<Tabs defaultValue="all">
<TabsList>
<TabsTrigger value="all">All ({profiles.length})</TabsTrigger>
<TabsTrigger value="tiktok">TikTok ({tiktokProfiles.length})</TabsTrigger>
<TabsTrigger value="instagram">Instagram ({instagramProfiles.length})</TabsTrigger>
<TabsTrigger value="youtube">YouTube ({youtubeProfiles.length})</TabsTrigger>
</TabsList>
<TabsContent value="all">
<ProfileTable profiles={profiles} onSelect={setSelectedProfile} />
</TabsContent>
<TabsContent value="tiktok">
<ProfileTable profiles={tiktokProfiles} onSelect={setSelectedProfile} />
</TabsContent>
<TabsContent value="instagram">
<ProfileTable profiles={instagramProfiles} onSelect={setSelectedProfile} />
</TabsContent>
<TabsContent value="youtube">
<ProfileTable profiles={youtubeProfiles} onSelect={setSelectedProfile} />
</TabsContent>
</Tabs>
</CardContent>
</Card>
</div>
{/* Growth Chart */}
<div>
{selectedProfile ? (
<GrowthChart
profileId={selectedProfile.id}
title={`@${selectedProfile.username} Growth`}
/>
) : (
<Card>
<CardHeader>
<CardTitle>Growth Chart</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">
Select a profile to view growth trends
</p>
</CardContent>
</Card>
)}
</div>
</div>
</div>
</div>
);
}
Run the Dashboard
npm run dev
Open http://localhost:3000 and start adding profiles!
What You Built
A professional social media dashboard that:
- ✅ Tracks unlimited profiles across platforms
- ✅ Shows real-time follower counts
- ✅ Displays growth trends over time
- ✅ Works with TikTok, Instagram, and YouTube
- ✅ Looks clean and professional
Cost comparison:
- Sprout Social: $249/month
- Your dashboard: ~$29/month API costs (for typical usage)
Next Steps
- Add authentication - Protect your dashboard with NextAuth.js
- Add more platforms - Twitter, LinkedIn, Threads
- Add engagement tracking - Likes, comments, views per post
- Set up scheduled refresh - Cron job to update daily
- Add export functionality - CSV/PDF reports for clients
Related: See our social media reporting automation guide for agency workflows.
Need the data? Get your SociaVault API key and start building.
Found this helpful?
Share it with others who might benefit
Ready to Try SociaVault?
Start extracting social media data with our powerful API. No credit card required.