DEV Community

Goran
Goran

Posted on

How I Built a Free SEO Audit Tool with Next.js, Supabase, and Stripe in 1 Week

Last weekend I started building SiteGrade — a free instant SEO audit tool that gives any website an A–F letter grade and the top fixes ranked by impact. I just launched it. This post is the build log: the architecture decisions that worked, the ones that didn't, and the gotchas you'd save time knowing about.
If you're considering building a similar audit/scanner tool, or just want to see what a 2026 Next.js + Supabase + Stripe stack looks like in practice, this should be useful.

Why I built it

I got tired of family and friends asking me "how's the SEO on my website?" and not having a good answer to send back. Every existing tool I tried either cost $99+/month (Ahrefs, Semrush) or threw 200 metrics at people who just wanted to know if their site was OK.
The wedge I built around: one letter grade, three top fixes, plain English, no signup. The paid tier ($29/mo) re-audits weekly and emails the report so non-technical users can track improvements as their developer makes them.

The stack

Nothing exotic. Everything is the obvious choice for a 2026 indie SaaS, which is the whole point — boring stack means I spent zero time fighting infrastructure and 100% of my time on the actual audit logic.

  • Next.js 14 App Router — frontend + API routes in one repo
  • Supabase — Postgres + auth + RLS + file storage, EU-hosted for GDPR
  • Stripe — subscriptions + Billing Portal + webhooks
  • Resend — transactional email (audit reports, weekly reports, Supabase auth via Custom SMTP)
  • Vercel — hosting + cron jobs for the weekly re-audit
  • cheerio — HTML parsing for the audit checks
  • Google PageSpeed Insights API — Core Web Vitals + mobile performance

Total monthly infrastructure cost at zero traffic: ~$0 (everyone on free tiers). At 100 paying customers it'd still be under $30/mo.

The audit logic — what 15 checks look like in code

Each audit runs 15 checks and produces a score from 0-100, then maps that to a letter grade (A: 90+, B: 75+, C: 60+, D: 45+, F: below 45).
The checks are structured as pure functions that take parsed page data and return a result:

type CheckStatus = "pass" | "warning" | "fail" | "info" | "error";

interface CheckResult {
  id: string;
  name: string;
  status: CheckStatus;
  severity: "info" | "warning" | "critical";
  score: number; // 0-100 contribution to the overall score
  message: string;
  fix?: string;
}

type CheckFunction = (page: PageData) => CheckResult;
Enter fullscreen mode Exit fullscreen mode

For example, the meta description check is just:

export function checkMetaDescription(page: PageData): CheckResult {
  const $ = cheerio.load(page.html);
  const desc = $('meta[name="description"]').attr("content")?.trim() ?? "";

  if (!desc) {
    return {
      id: "meta-description",
      name: "Meta Description",
      status: "fail",
      severity: "critical",
      score: 0,
      message: "No meta description found. Google generates a snippet, often poorly.",
      fix: 'Add <meta name="description" content="..."> with 120-160 chars.',
    };
  }
  if (desc.length < 120 || desc.length > 160) {
    return { /* warning result */ };
  }
  return { /* pass result */ };
}
Enter fullscreen mode Exit fullscreen mode

15 of these, run in parallel via Promise.all. The whole audit completes in 3-5 seconds for HTML-only checks; the Google PageSpeed Insights call adds another 20-30 seconds (Google's mobile audit is slow). Total audit time: ~30 seconds. I show a loading state with progress copy so the wait feels intentional.

The fetcher gotcha — sites block your auditor

The first version of my fetcher used a single fake browser User-Agent. About 25% of sites returned 403 or showed a Cloudflare bot challenge. That's a horrible UX — users type their URL, wait 30 seconds, and get "Site blocks our auditor."
The fix that recovered most of those sites: multi-pass fetch with UA rotation. Try a real-browser UA first; if blocked, retry with Googlebot's UA; if still blocked, try Bingbot. Many WAFs whitelist Googlebot by default because blocking Google would tank the site's rankings.

const USER_AGENTS = [
  "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
  // ...3 more real browser UAs
];

const FALLBACK_UAS = [
  "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)",
  "Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)",
];

export async function fetchPage(url: string): Promise<PageData> {
  let result = await fetchAttempt(url, getRandomBrowserUA());
  if (result) return result;

  result = await fetchAttempt(url, FALLBACK_UAS[0]); // Googlebot
  if (result) return result;

  result = await fetchAttempt(url, FALLBACK_UAS[1]); // Bingbot
  if (result) return result;

  throw new AuditError({
    code: "blocked",
    message: "Site blocks our auditor — check robots.txt",
  });
}
Enter fullscreen mode Exit fullscreen mode

This single change cut my "blocked" error rate from ~25% to ~6%.
Detection of "is this actually blocked" matters too. A 403 with a Cloudflare server header → blocked. A 200 with HTML containing "checking your browser" / "captcha" / "just a moment..." → blocked (the site returned 200 but the body is a challenge page). A 200 with a tiny response body (<4KB) on a 403/406/429/503 → probably blocked. Each rule trims false positives.

The Stripe webhook gotcha — your single point of failure isn't single enough

This one cost me a day of debugging. The standard pattern:

  1. User clicks "Subscribe" → Stripe Checkout
  2. User pays → Stripe sends checkout.session.completed webhook
  3. Your webhook handler looks up the user, updates their profile to plan: "starter"

What happens when step 2 succeeds but step 3 fails? The user gets charged but is still on the free tier. In dev this happened to me when the Stripe CLI wasn't running. In prod it can happen from a transient outage, signature secret mismatch, or RLS misconfig.
The fix: defense in depth. The webhook is the primary path, but every place the app checks the user's subscription, it ALSO reconciles from Stripe directly if the local data looks stale.
I built a syncProfileFromStripe() helper that:

  • Lists all Stripe customers for the user's email (handles duplicate customers from buggy double-checkout)
  • Finds the canonical (highest-tier) currently-active subscription
  • Writes the result back to the Supabase profile
  • Optionally cancels duplicate active subs with refund proration
export async function syncProfileFromStripe({
  userId,
  email,
  cancelDuplicates = false,
}: SyncOpts): Promise<SyncResult> {
  const customers = await stripe.customers.list({ email, limit: 10 });
  // ...collect all subs across all customers
  // ...rank by plan tier + recency
  // ...write canonical state to profiles table
  // ...optionally cancel extras
}
Enter fullscreen mode Exit fullscreen mode

This runs from two places:

  • Dashboard on ?checkout=success — so even if the webhook silently fails, the dashboard self-heals the moment the user lands on it
  • Pricing page action before deciding to checkout — so the app never double-bills someone who already has an active sub

The webhook is still the primary path (faster, less Stripe API quota usage), but it's no longer a single point of failure. The user pays → they get their plan within milliseconds, regardless of webhook state.

The cron architecture — weekly re-audits without a queue

For the paid tier (weekly automated reports), I needed something to re-run audits every Monday and email the results. Three options:

  1. A real job queue (BullMQ + Redis) → overkill for <1000 customers
  2. Inngest or Trigger.dev → another vendor, another bill
  3. Vercel Cron hitting an authenticated API route → free, dead simple

I went with option 3. vercel.json:

{
  "crons": [{ "path": "/api/cron/weekly-audits", "schedule": "0 8 * * 1" }]
}
Enter fullscreen mode Exit fullscreen mode

Every Monday at 08:00 UTC, Vercel hits /api/cron/weekly-audits. The route checks for a bearer token (CRON_SECRET env var), then loops through every paying user → audits each of their sites sequentially → sends each report via Resend with React Email templates → updates sites.last_score and sites.last_audit_at.

export async function GET(request: NextRequest) {
  if (request.headers.get("authorization") !== `Bearer ${process.env.CRON_SECRET}`) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }
  // ...fetch active sites for paying users
  // ...for each: runAudit() → insert audit row → update site → sendEmail()
}
Enter fullscreen mode Exit fullscreen mode

300ms sleep between sites to respect the PageSpeed Insights API rate limit (Google's free tier is 25K requests/day, generous but worth respecting). At 100 paying customers × 1-5 sites each, the whole Monday cron run takes 5-10 minutes. Vercel's free-tier function timeout is 60 seconds; the cron-route timeout on Pro is 5 minutes. I'll need to chunk the work when I cross ~500 sites — probably switch to Vercel's streaming model or split into multiple cron invocations by hash bucket.

What I'd do differently

A few things in hindsight:
Set up Supabase Custom SMTP from day 1, not as an afterthought. Supabase's default SMTP is rate-limited to 3-4 emails per hour per project and uses noreply@mail.app.supabase.io (which Gmail dumps to spam). For any production launch you must switch to Custom SMTP pointing at Resend. I figured this out the day before launch, which was 18 hours of unnecessary stress.
Probably MDX or content collections for the blog, not what I did (typed TSX files per post). My approach was zero-dep and works fine, but adding a new post means editing a registry. MDX would let me write .mdx files with frontmatter and have them auto-discovered.
Send-Mail-As + ImprovMX for inbound mail I knew about earlier, would've saved me trying to find a domain registrar that does email forwarding for .xyz TLDs (most don't — they dropped support for cheaper TLDs). ImprovMX is free for up to 25 aliases and takes 5 minutes to set up.
Build the live audit endpoint as a public API from day 1. Some users will want to integrate the audit into their own dashboards. Exposing it as a documented API would let me sell a higher-tier plan to agencies. Adding now means migrating all the existing routes; doing it on day 1 would've been free.

What's next

Marketing. I'm posting this article, doing a Twitter thread, submitting to Show HN, Product Hunt next week. The code is done; the question is now whether I can find the customers.
If you want to see the live tool, it's at sitegrade.xyz — runs in 30 seconds, no signup. I'd love feedback from other founders on:

The audit results UX — is the score wheel + list clear, or overwhelming?
The pricing tiers — is $29/mo too high for one site, about right?
The "weekly report" promise — would you actually open one if it landed in your inbox every Monday?

Happy to answer any questions about the build in the comments — tech stack details, the cron architecture, the Stripe edge cases, anything.
Building solo, learning publicly. Thanks for reading.
— Goran

Top comments (0)