DEV Community

Cover image for Ship a Micro-SaaS in a Weekend: Influencer Vetting Tool (Full Stack)
Olamide Olaniyan
Olamide Olaniyan

Posted on

Ship a Micro-SaaS in a Weekend: Influencer Vetting Tool (Full Stack)

I keep seeing "build a SaaS in a weekend" posts that end with a login page and a Stripe checkout. Cool. What about the actual product?

Here's a real one. An influencer vetting tool that agencies and brands actually pay for. The whole thing — frontend, backend, Stripe, the works — in under 400 lines of meaningful code.

The Product

A simple tool: paste a creator's Instagram or TikTok handle, get a one-page report that tells you if this creator is worth partnering with.

The report shows:

  • Real engagement rate (calculated from actual posts, not estimated)
  • Audience quality signals (fake follower indicators)
  • Posting consistency
  • Growth trajectory
  • A simple Go / Caution / Avoid recommendation

Free tier: 3 reports/month. Paid: $19/month for unlimited.

Why This Makes Money

Agencies vet 50-100 creators per campaign. They currently do it manually — checking profiles, calculating engagement rates in spreadsheets, gut-feeling whether followers look real.

Charge $19/month. Get 100 agencies. That's $1,900/month from a weekend project.

The Stack

  • Next.js 14 (App Router) – frontend + API routes
  • SociaVault API – all the social data
  • Stripe – payments
  • Clerk – auth (or roll your own, but why)
  • Vercel – deploy

Project Structure

influencer-vetter/
├── app/
│   ├── page.tsx              # Landing + search
│   ├── report/[handle]/page.tsx  # The report page
│   ├── api/
│   │   ├── vet/route.ts         # Generate report
│   │   └── webhook/route.ts     # Stripe webhook
│   └── layout.tsx
├── lib/
│   ├── scoring.ts            # The scoring engine
│   ├── stripe.ts             # Stripe helpers
│   └── rate-limit.ts         # Usage tracking
└── package.json
Enter fullscreen mode Exit fullscreen mode

The Scoring Engine

This is the brain. Everything else is plumbing.

// lib/scoring.ts

interface CreatorReport {
  handle: string;
  platform: string;
  followers: number;
  engagementRate: number;
  avgLikes: number;
  avgComments: number;
  likeCommentRatio: number;
  postingFrequency: number; // posts per week
  consistencyScore: number; // 0-100
  authenticityScore: number; // 0-100
  overallScore: number; // 0-100
  verdict: 'go' | 'caution' | 'avoid';
  flags: string[];
}

export function analyzeCreator(profile: any, posts: any[]): CreatorReport {
  const followers = profile.followersCount || profile.followerCount || 0;
  const flags: string[] = [];

  // --- Engagement Rate ---
  const totalLikes = posts.reduce((s, p) => s + (p.likesCount || p.diggCount || 0), 0);
  const totalComments = posts.reduce((s, p) => s + (p.commentsCount || p.commentCount || 0), 0);
  const avgLikes = posts.length > 0 ? totalLikes / posts.length : 0;
  const avgComments = posts.length > 0 ? totalComments / posts.length : 0;
  const engagementRate = followers > 0 ? ((avgLikes + avgComments) / followers) * 100 : 0;

  // --- Like-to-Comment Ratio ---
  // Healthy: 10:1 to 50:1. Above 100:1 = likely bought likes
  const likeCommentRatio = avgComments > 0 ? avgLikes / avgComments : 0;
  if (likeCommentRatio > 100) flags.push('Suspiciously high like-to-comment ratio');
  if (likeCommentRatio > 200) flags.push('Likely purchased likes or bot engagement');

  // --- Posting Frequency ---
  const timestamps = posts
    .map(p => new Date(p.timestamp || p.createTime * 1000).getTime())
    .filter(t => !isNaN(t))
    .sort((a, b) => b - a);

  let postsPerWeek = 0;
  if (timestamps.length >= 2) {
    const spanDays = (timestamps[0] - timestamps[timestamps.length - 1]) / 86400000;
    postsPerWeek = spanDays > 0 ? (posts.length / spanDays) * 7 : 0;
  }

  // --- Consistency Score ---
  // Measures how regular their posting cadence is
  let consistencyScore = 50;
  if (timestamps.length >= 3) {
    const gaps = [];
    for (let i = 0; i < timestamps.length - 1; i++) {
      gaps.push(timestamps[i] - timestamps[i + 1]);
    }
    const avgGap = gaps.reduce((s, g) => s + g, 0) / gaps.length;
    const variance = gaps.reduce((s, g) => s + Math.pow(g - avgGap, 2), 0) / gaps.length;
    const stdDev = Math.sqrt(variance);
    const cv = avgGap > 0 ? stdDev / avgGap : 1; // Coefficient of variation

    // Lower CV = more consistent. CV of 0.3 = very consistent, 1.0+ = chaotic
    consistencyScore = Math.max(0, Math.min(100, Math.round((1 - Math.min(cv, 1)) * 100)));
  }

  if (postsPerWeek < 1) flags.push('Posts less than once per week');
  if (consistencyScore < 30) flags.push('Very inconsistent posting schedule');

  // --- Authenticity Score ---
  let authenticityScore = 100;

  // Follower/following ratio
  const following = profile.followingCount || 0;
  const ffRatio = following > 0 ? followers / following : followers;
  if (ffRatio < 1 && followers > 1000) {
    authenticityScore -= 20;
    flags.push('Following more people than followers (follow-for-follow pattern)');
  }

  // Engagement vs follower count sanity check
  if (followers > 100000 && engagementRate < 0.5) {
    authenticityScore -= 25;
    flags.push('Very low engagement for follower count — possible ghost followers');
  }

  // Like-comment ratio penalty
  if (likeCommentRatio > 100) authenticityScore -= 15;
  if (likeCommentRatio > 200) authenticityScore -= 20;

  authenticityScore = Math.max(0, authenticityScore);

  // --- Overall Score ---
  // Weighted: engagement quality matters most
  const overallScore = Math.round(
    authenticityScore * 0.4 +
    consistencyScore * 0.2 +
    Math.min(engagementRate * 10, 100) * 0.3 + // Cap at 10% ER = 100 points
    Math.min(postsPerWeek * 15, 100) * 0.1      // Cap at ~7 posts/week = 100 points
  );

  // --- Verdict ---
  let verdict: 'go' | 'caution' | 'avoid';
  if (overallScore >= 70 && flags.length <= 1) verdict = 'go';
  else if (overallScore >= 40 || flags.length <= 2) verdict = 'caution';
  else verdict = 'avoid';

  return {
    handle: profile.username || profile.uniqueId,
    platform: profile.platform || 'unknown',
    followers,
    engagementRate: parseFloat(engagementRate.toFixed(2)),
    avgLikes: Math.round(avgLikes),
    avgComments: Math.round(avgComments),
    likeCommentRatio: parseFloat(likeCommentRatio.toFixed(1)),
    postingFrequency: parseFloat(postsPerWeek.toFixed(1)),
    consistencyScore,
    authenticityScore,
    overallScore,
    verdict,
    flags,
  };
}
Enter fullscreen mode Exit fullscreen mode

That's the entire scoring engine. ~100 lines. No AI, no machine learning. Just math that makes sense.

The API Route

// app/api/vet/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { analyzeCreator } from '@/lib/scoring';

const SOCIAVAULT_API = 'https://api.sociavault.com/v1/scrape';
const API_KEY = process.env.SOCIAVAULT_API_KEY!;

async function fetchFromSociaVault(endpoint: string) {
  const res = await fetch(`${SOCIAVAULT_API}${endpoint}`, {
    headers: { 'x-api-key': API_KEY },
  });
  if (!res.ok) throw new Error(`API error: ${res.status}`);
  return res.json();
}

export async function GET(req: NextRequest) {
  const handle = req.nextUrl.searchParams.get('handle');
  const platform = req.nextUrl.searchParams.get('platform') || 'instagram';

  if (!handle) {
    return NextResponse.json({ error: 'Missing handle' }, { status: 400 });
  }

  try {
    let profile, posts;

    if (platform === 'instagram') {
      [profile, posts] = await Promise.all([
        fetchFromSociaVault(`/instagram/profile?username=${handle}`),
        fetchFromSociaVault(`/instagram/posts?username=${handle}&limit=12`),
      ]);
    } else {
      [profile, posts] = await Promise.all([
        fetchFromSociaVault(`/tiktok/profile?username=${handle}`),
        fetchFromSociaVault(`/tiktok/profile-videos?username=${handle}&limit=12`),
      ]);
    }

    const profileData = profile.data || profile;
    const postsData = posts.data || posts.posts || [];

    const report = analyzeCreator(
      { ...profileData, platform },
      postsData
    );

    return NextResponse.json(report);
  } catch (err: any) {
    return NextResponse.json({ error: err.message }, { status: 500 });
  }
}
Enter fullscreen mode Exit fullscreen mode

The Frontend (Simplified)

I'm not going to paste an entire React frontend — you've seen search bars before. The key parts:

// app/page.tsx — the search
'use client';

import { useState } from 'react';
import { useRouter } from 'next/navigation';

export default function Home() {
  const [handle, setHandle] = useState('');
  const [platform, setPlatform] = useState('instagram');
  const router = useRouter();

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (handle.trim()) {
      router.push(`/report/${handle.trim().replace('@', '')}?platform=${platform}`);
    }
  };

  return (
    <main className="min-h-screen flex items-center justify-center">
      <form onSubmit={handleSubmit} className="w-full max-w-md">
        <h1 className="text-3xl font-bold mb-8 text-center">
          Should you partner with this creator?
        </h1>
        <div className="flex gap-2 mb-4">
          <button
            type="button"
            onClick={() => setPlatform('instagram')}
            className={platform === 'instagram' ? 'bg-pink-500 text-white px-4 py-2 rounded' : 'bg-gray-200 px-4 py-2 rounded'}
          >
            Instagram
          </button>
          <button
            type="button"
            onClick={() => setPlatform('tiktok')}
            className={platform === 'tiktok' ? 'bg-black text-white px-4 py-2 rounded' : 'bg-gray-200 px-4 py-2 rounded'}
          >
            TikTok
          </button>
        </div>
        <input
          type="text"
          value={handle}
          onChange={(e) => setHandle(e.target.value)}
          placeholder="Enter username..."
          className="w-full px-4 py-3 border rounded-lg text-lg"
        />
        <button type="submit" className="w-full mt-4 bg-blue-600 text-white py-3 rounded-lg text-lg font-medium">
          Analyze Creator
        </button>
      </form>
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

The report page calls /api/vet?handle=xxx&platform=yyy and renders the result. Show the overall score big. Color code the verdict. List the flags as warnings. Done.

Monetization with Stripe

// lib/rate-limit.ts
// Track usage per user per month using a simple in-memory map
// (For production, use Redis or your database)

const usage = new Map<string, { count: number; resetAt: number }>();

export function checkUsage(userId: string, isPaid: boolean): boolean {
  const limit = isPaid ? Infinity : 3;
  const now = Date.now();
  const key = userId;

  const record = usage.get(key);
  if (!record || record.resetAt < now) {
    // New month
    usage.set(key, { count: 1, resetAt: now + 30 * 86400000 });
    return true;
  }

  if (record.count >= limit) return false;

  record.count++;
  return true;
}
Enter fullscreen mode Exit fullscreen mode

Deploy

# Push to GitHub, connect to Vercel, done
git init && git add -A && git commit -m "initial"
vercel
Enter fullscreen mode Exit fullscreen mode

Total cost:

  • Vercel: $0 (free tier)
  • SociaVault API: $29/month (Starter plan, 5K credits)
  • Stripe: 2.9% + $0.30 per transaction
  • Clerk: $0 (free tier up to 10K users)

At 10 paying users ($19/month each), you're making $190/month and spending ~$30. That's a 6:1 return from a weekend project.

What Makes This Win

It's not the code. It's the positioning.

HypeAuditor charges $299/month for this. CreatorIQ won't even talk to you without a demo call. You're offering the same core value for $19/month with zero friction.

The scoring engine above does 80% of what the expensive tools do. The other 20% is pretty charts and PDF exports — which you can add later.

Read the Full Guide

Build an Influencer Vetting Micro-SaaS → SociaVault Blog


Power your SaaS with social media data from SociaVault — one API for TikTok, Instagram, YouTube, and 10+ platforms. Profiles, posts, engagement, followers — all endpoints, one API key.

Discussion

Have you shipped a micro-SaaS in a weekend? What was it, and how did it go? I'm always curious about the gap between "launched" and "first paying customer."

javascript #saas #webdev #startup #indiehackers

Top comments (0)