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;
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 */ };
}
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",
});
}
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:
- User clicks "Subscribe" → Stripe Checkout
- User pays → Stripe sends checkout.session.completed webhook
- 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
}
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:
- A real job queue (BullMQ + Redis) → overkill for <1000 customers
- Inngest or Trigger.dev → another vendor, another bill
- 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" }]
}
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()
}
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)