Back to Blog
Tutorial

How to Build a Social Media Dashboard with Next.js (Complete Tutorial)

January 16, 2026
12 min read
S
By SociaVault Team
Next.jsDashboardSocial Media AnalyticsReactTutorial

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:

  1. Tracks multiple social media profiles across platforms
  2. Displays key metrics (followers, engagement, views)
  3. Shows growth trends over time
  4. Compares performance across accounts
  5. 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

  1. Add authentication - Protect your dashboard with NextAuth.js
  2. Add more platforms - Twitter, LinkedIn, Threads
  3. Add engagement tracking - Likes, comments, views per post
  4. Set up scheduled refresh - Cron job to update daily
  5. 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.