DEV Community

Olamide Olaniyan
Olamide Olaniyan

Posted on

Build a Real-Time Social Proof Widget for Your Website

"10,000+ followers on Twitter" means nothing if visitors can't verify it.

Social proof widgets do one thing: show real numbers that build trust instantly.

But most widget tools either look terrible, cost $50/month, or show stale data from 3 weeks ago.

In this tutorial, we'll build a Real-Time Social Proof Widget that:

  1. Pulls live follower counts from multiple platforms
  2. Renders a clean, embeddable widget
  3. Auto-updates without manual refreshes

Perfect for landing pages, creator portfolios, and SaaS homepages.

Why Social Proof Matters

Quick psychology lesson:

  • 92% of consumers trust peer recommendations over advertising
  • Pages with social proof convert 34% better
  • "Join 50,000+ users" outperforms "Sign up today"

Numbers create credibility. Real-time numbers create more credibility.

The Stack

  • Node.js + Express: Backend API
  • SociaVault API: To fetch social stats
  • HTML/CSS/JS: Embeddable widget
  • Optional: Redis: For caching (recommended)

Step 1: Setup

mkdir social-proof-widget
cd social-proof-widget
npm init -y
npm install express axios dotenv cors
Enter fullscreen mode Exit fullscreen mode

Create .env:

SOCIAVAULT_API_KEY=your_sociavault_key
PORT=3000
Enter fullscreen mode Exit fullscreen mode

Step 2: The Stats API

Create server.js:

require('dotenv').config();
const express = require('express');
const axios = require('axios');
const cors = require('cors');

const app = express();
app.use(cors()); // Allow embedding on other sites
app.use(express.static('public'));

const SOCIAVAULT_BASE = 'https://api.sociavault.com';
const headers = { 'Authorization': `Bearer ${process.env.SOCIAVAULT_API_KEY}` };

// Cache to avoid hammering the API
const cache = new Map();
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes

async function getCachedOrFetch(key, fetchFn) {
  const cached = cache.get(key);
  if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
    return cached.data;
  }

  const data = await fetchFn();
  cache.set(key, { data, timestamp: Date.now() });
  return data;
}

// TikTok stats
async function getTikTokStats(handle) {
  try {
    const response = await axios.get(`${SOCIAVAULT_BASE}/v1/scrape/tiktok/profile`, {
      params: { handle },
      headers
    });

    const data = response.data.data;
    return {
      platform: 'tiktok',
      handle,
      followers: data.followerCount || data.fans || 0,
      likes: data.heartCount || data.heart || 0,
      videos: data.videoCount || data.video || 0,
      verified: data.verified || false
    };
  } catch (error) {
    console.error(`TikTok error for @${handle}:`, error.message);
    return null;
  }
}

// Instagram stats
async function getInstagramStats(handle) {
  try {
    const response = await axios.get(`${SOCIAVAULT_BASE}/v1/scrape/instagram/profile`, {
      params: { handle },
      headers
    });

    const data = response.data.data;
    return {
      platform: 'instagram',
      handle,
      followers: data.follower_count || data.followers || 0,
      following: data.following_count || 0,
      posts: data.media_count || 0,
      verified: data.is_verified || false
    };
  } catch (error) {
    console.error(`Instagram error for @${handle}:`, error.message);
    return null;
  }
}

// Twitter stats
async function getTwitterStats(handle) {
  try {
    const response = await axios.get(`${SOCIAVAULT_BASE}/v1/scrape/twitter/profile`, {
      params: { handle },
      headers
    });

    const data = response.data.data;
    return {
      platform: 'twitter',
      handle,
      followers: data.followers_count || data.followers || 0,
      following: data.friends_count || data.following || 0,
      tweets: data.statuses_count || data.tweets || 0,
      verified: data.verified || data.is_blue_verified || false
    };
  } catch (error) {
    console.error(`Twitter error for @${handle}:`, error.message);
    return null;
  }
}

// YouTube stats
async function getYouTubeStats(channelUrl) {
  try {
    const response = await axios.get(`${SOCIAVAULT_BASE}/v1/scrape/youtube/channel`, {
      params: { url: channelUrl },
      headers
    });

    const data = response.data.data;
    return {
      platform: 'youtube',
      handle: data.name || data.title,
      subscribers: data.subscriberCount || data.subscribers || 0,
      videos: data.videoCount || data.videos || 0,
      views: data.viewCount || data.totalViews || 0,
      verified: data.verified || false
    };
  } catch (error) {
    console.error(`YouTube error:`, error.message);
    return null;
  }
}

// LinkedIn stats
async function getLinkedInStats(profileUrl) {
  try {
    const response = await axios.get(`${SOCIAVAULT_BASE}/v1/scrape/linkedin/profile`, {
      params: { url: profileUrl },
      headers
    });

    const data = response.data.data;
    return {
      platform: 'linkedin',
      handle: data.username || data.public_id,
      followers: data.follower_count || data.followers || 0,
      connections: data.connections_count || 0
    };
  } catch (error) {
    console.error(`LinkedIn error:`, error.message);
    return null;
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 3: API Endpoints

Add routes to serve the stats:

// Single platform endpoint
app.get('/api/stats/:platform/:handle', async (req, res) => {
  const { platform, handle } = req.params;
  const cacheKey = `${platform}:${handle}`;

  try {
    const stats = await getCachedOrFetch(cacheKey, async () => {
      switch (platform) {
        case 'tiktok': return getTikTokStats(handle);
        case 'instagram': return getInstagramStats(handle);
        case 'twitter': return getTwitterStats(handle);
        default: return null;
      }
    });

    if (!stats) {
      return res.status(404).json({ error: 'Profile not found' });
    }

    res.json(stats);
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

// Multi-platform bundle
app.get('/api/stats/bundle', async (req, res) => {
  const { tiktok, instagram, twitter, youtube, linkedin } = req.query;

  const promises = [];

  if (tiktok) {
    promises.push(getCachedOrFetch(`tiktok:${tiktok}`, () => getTikTokStats(tiktok)));
  }
  if (instagram) {
    promises.push(getCachedOrFetch(`instagram:${instagram}`, () => getInstagramStats(instagram)));
  }
  if (twitter) {
    promises.push(getCachedOrFetch(`twitter:${twitter}`, () => getTwitterStats(twitter)));
  }
  if (youtube) {
    promises.push(getCachedOrFetch(`youtube:${youtube}`, () => getYouTubeStats(youtube)));
  }
  if (linkedin) {
    promises.push(getCachedOrFetch(`linkedin:${linkedin}`, () => getLinkedInStats(linkedin)));
  }

  const results = await Promise.all(promises);
  const stats = results.filter(Boolean);

  // Calculate totals
  const totalFollowers = stats.reduce((sum, s) => {
    return sum + (s.followers || s.subscribers || 0);
  }, 0);

  res.json({
    platforms: stats,
    totals: {
      platforms: stats.length,
      followers: totalFollowers
    }
  });
});

app.listen(process.env.PORT || 3000, () => {
  console.log(`Stats API running on port ${process.env.PORT || 3000}`);
});
Enter fullscreen mode Exit fullscreen mode

Step 4: The Embeddable Widget

Create public/widget.js:

(function() {
  // Widget configuration
  const WIDGET_API = 'YOUR_API_URL'; // Replace with your deployed URL

  class SocialProofWidget {
    constructor(config) {
      this.container = document.querySelector(config.selector);
      this.handles = config.handles || {};
      this.theme = config.theme || 'light';
      this.style = config.style || 'horizontal';
      this.showTotal = config.showTotal !== false;

      this.init();
    }

    async init() {
      this.injectStyles();
      this.render('loading');

      try {
        const stats = await this.fetchStats();
        this.render('loaded', stats);
      } catch (error) {
        this.render('error', error.message);
      }
    }

    async fetchStats() {
      const params = new URLSearchParams(this.handles).toString();
      const response = await fetch(`${WIDGET_API}/api/stats/bundle?${params}`);

      if (!response.ok) throw new Error('Failed to fetch stats');

      return response.json();
    }

    formatNumber(num) {
      if (num >= 1000000) {
        return (num / 1000000).toFixed(1) + 'M';
      }
      if (num >= 1000) {
        return (num / 1000).toFixed(1) + 'K';
      }
      return num.toString();
    }

    getPlatformIcon(platform) {
      const icons = {
        tiktok: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19.59 6.69a4.83 4.83 0 0 1-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 0 1-5.2 1.74 2.89 2.89 0 0 1 2.31-4.64 2.93 2.93 0 0 1 .88.13V9.4a6.84 6.84 0 0 0-1-.05A6.33 6.33 0 0 0 5 20.1a6.34 6.34 0 0 0 10.86-4.43v-7a8.16 8.16 0 0 0 4.77 1.52v-3.4a4.85 4.85 0 0 1-1-.1z"/></svg>',
        instagram: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z"/></svg>',
        twitter: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>',
        youtube: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></svg>',
        linkedin: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>'
      };
      return icons[platform] || '';
    }

    getPlatformColor(platform) {
      const colors = {
        tiktok: '#000000',
        instagram: '#E4405F',
        twitter: '#000000',
        youtube: '#FF0000',
        linkedin: '#0A66C2'
      };
      return colors[platform] || '#666666';
    }

    injectStyles() {
      if (document.querySelector('#social-proof-styles')) return;

      const styles = document.createElement('style');
      styles.id = 'social-proof-styles';
      styles.textContent = `
        .sp-widget {
          font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
          display: flex;
          gap: 16px;
          padding: 16px;
          border-radius: 12px;
          background: ${this.theme === 'dark' ? '#1a1a1a' : '#ffffff'};
          color: ${this.theme === 'dark' ? '#ffffff' : '#1a1a1a'};
          box-shadow: 0 2px 8px rgba(0,0,0,0.1);
        }
        .sp-widget.vertical { flex-direction: column; }
        .sp-widget.horizontal { flex-direction: row; flex-wrap: wrap; justify-content: center; }
        .sp-platform {
          display: flex;
          align-items: center;
          gap: 8px;
          padding: 8px 16px;
          border-radius: 8px;
          background: ${this.theme === 'dark' ? '#2a2a2a' : '#f5f5f5'};
          transition: transform 0.2s;
        }
        .sp-platform:hover { transform: translateY(-2px); }
        .sp-platform svg { width: 20px; height: 20px; }
        .sp-platform-stats { display: flex; flex-direction: column; }
        .sp-followers { font-weight: 700; font-size: 18px; }
        .sp-label { font-size: 12px; opacity: 0.7; }
        .sp-total {
          text-align: center;
          padding: 12px;
          border-top: 1px solid ${this.theme === 'dark' ? '#333' : '#eee'};
          margin-top: 8px;
        }
        .sp-total-number { font-size: 24px; font-weight: 700; }
        .sp-total-label { font-size: 14px; opacity: 0.7; }
        .sp-loading { padding: 20px; text-align: center; opacity: 0.5; }
        .sp-verified { color: #1DA1F2; margin-left: 4px; }
      `;
      document.head.appendChild(styles);
    }

    render(state, data) {
      if (state === 'loading') {
        this.container.innerHTML = `
          <div class="sp-widget ${this.style}">
            <div class="sp-loading">Loading social stats...</div>
          </div>
        `;
        return;
      }

      if (state === 'error') {
        this.container.innerHTML = `
          <div class="sp-widget ${this.style}">
            <div class="sp-loading">Unable to load stats</div>
          </div>
        `;
        return;
      }

      const platformsHtml = data.platforms.map(p => `
        <div class="sp-platform" style="color: ${this.getPlatformColor(p.platform)}">
          ${this.getPlatformIcon(p.platform)}
          <div class="sp-platform-stats">
            <span class="sp-followers">
              ${this.formatNumber(p.followers || p.subscribers)}
              ${p.verified ? '<span class="sp-verified">✓</span>' : ''}
            </span>
            <span class="sp-label">@${p.handle}</span>
          </div>
        </div>
      `).join('');

      const totalHtml = this.showTotal ? `
        <div class="sp-total">
          <div class="sp-total-number">${this.formatNumber(data.totals.followers)}</div>
          <div class="sp-total-label">Total Followers</div>
        </div>
      ` : '';

      this.container.innerHTML = `
        <div class="sp-widget ${this.style}">
          ${platformsHtml}
          ${totalHtml}
        </div>
      `;
    }
  }

  // Expose globally
  window.SocialProofWidget = SocialProofWidget;
})();
Enter fullscreen mode Exit fullscreen mode

Step 5: The Embed Code

Users can embed the widget with:

<!-- Add this where you want the widget -->
<div id="social-proof"></div>

<!-- Add this before </body> -->
<script src="https://your-api.com/widget.js"></script>
<script>
  new SocialProofWidget({
    selector: '#social-proof',
    handles: {
      tiktok: 'garyvee',
      instagram: 'garyvee',
      twitter: 'garyvee',
      youtube: 'https://youtube.com/@garyvee'
    },
    theme: 'light', // 'light' or 'dark'
    style: 'horizontal', // 'horizontal' or 'vertical'
    showTotal: true
  });
</script>
Enter fullscreen mode Exit fullscreen mode

Step 6: Self-Hosted Demo Page

Create public/index.html:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Social Proof Widget Demo</title>
  <style>
    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
      max-width: 800px;
      margin: 0 auto;
      padding: 40px 20px;
      background: #f5f5f5;
    }
    h1 { text-align: center; }
    .demo-section {
      background: white;
      padding: 30px;
      border-radius: 12px;
      margin: 30px 0;
      box-shadow: 0 2px 8px rgba(0,0,0,0.1);
    }
    h2 { margin-top: 0; }
    .embed-code {
      background: #1a1a1a;
      color: #fff;
      padding: 20px;
      border-radius: 8px;
      overflow-x: auto;
      font-family: monospace;
      font-size: 14px;
    }
  </style>
</head>
<body>
  <h1>🏆 Social Proof Widget</h1>

  <div class="demo-section">
    <h2>Horizontal Layout (Light)</h2>
    <div id="widget-horizontal"></div>
  </div>

  <div class="demo-section" style="background: #1a1a1a;">
    <h2 style="color: white;">Vertical Layout (Dark)</h2>
    <div id="widget-vertical"></div>
  </div>

  <div class="demo-section">
    <h2>Your Embed Code</h2>
    <div class="embed-code">
&lt;div id="social-proof"&gt;&lt;/div&gt;
&lt;script src="https://your-domain.com/widget.js"&gt;&lt;/script&gt;
&lt;script&gt;
  new SocialProofWidget({
    selector: '#social-proof',
    handles: {
      tiktok: 'your_handle',
      instagram: 'your_handle',
      twitter: 'your_handle'
    }
  });
&lt;/script&gt;
    </div>
  </div>

  <script src="/widget.js"></script>
  <script>
    // Demo with real data
    new SocialProofWidget({
      selector: '#widget-horizontal',
      handles: {
        tiktok: 'khaby.lame',
        instagram: 'khabylame',
        twitter: 'KhabyLame'
      },
      theme: 'light',
      style: 'horizontal',
      showTotal: true
    });

    new SocialProofWidget({
      selector: '#widget-vertical',
      handles: {
        tiktok: 'charlidamelio',
        instagram: 'charlidamelio'
      },
      theme: 'dark',
      style: 'vertical',
      showTotal: true
    });
  </script>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

Step 7: Deploy

Deploy your API anywhere (Vercel, Railway, Render):

# Example: Deploy to Railway
npm install -g @railway/cli
railway login
railway init
railway up
Enter fullscreen mode Exit fullscreen mode

Update WIDGET_API in widget.js to your deployed URL.

Adding Redis Caching (Production)

For high-traffic sites, add Redis:

const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL);

async function getCachedOrFetch(key, fetchFn, ttl = 300) {
  // Try cache first
  const cached = await redis.get(key);
  if (cached) {
    return JSON.parse(cached);
  }

  // Fetch fresh data
  const data = await fetchFn();

  // Cache with TTL
  await redis.setex(key, ttl, JSON.stringify(data));

  return data;
}
Enter fullscreen mode Exit fullscreen mode

Customization Options

Different Widget Styles

// Minimal - just numbers
new SocialProofWidget({
  selector: '#minimal',
  handles: { twitter: 'username' },
  style: 'minimal' // Shows "50K followers" only
});

// Badge style
new SocialProofWidget({
  selector: '#badge',
  handles: { tiktok: 'username' },
  style: 'badge' // Compact badge format
});
Enter fullscreen mode Exit fullscreen mode

Animation on Load

.sp-platform {
  opacity: 0;
  transform: translateY(20px);
  animation: fadeUp 0.5s forwards;
}

.sp-platform:nth-child(1) { animation-delay: 0.1s; }
.sp-platform:nth-child(2) { animation-delay: 0.2s; }
.sp-platform:nth-child(3) { animation-delay: 0.3s; }

@keyframes fadeUp {
  to {
    opacity: 1;
    transform: translateY(0);
  }
}
Enter fullscreen mode Exit fullscreen mode

What This Would Cost Elsewhere

Social proof widget services:

  • SocialProof.io: $29/month
  • Proof.com: $79/month
  • UseProof: $99/month

Your self-hosted version:

  • SociaVault: ~$0.004 per stat refresh
  • Hosting: $0-5/month
  • Total: Nearly free

Plus you own the data and can customize everything.

Real-World Use Cases

1. Landing Pages

Show follower counts to build credibility for a new product.

2. Creator Portfolios

Let potential sponsors see your real numbers instantly.

3. Media Kits

Embed live stats that auto-update (no more outdated screenshots).

4. SaaS Dashboards

Show user growth stats to your investors in real-time.

Get Started

  1. Get your SociaVault API Key
  2. Deploy the API
  3. Embed the widget on your site
  4. Watch your conversion rate climb

Real numbers. Real-time. Real credibility.


Social proof isn't just vanity metrics. It's trust at scale.

Top comments (0)