DEV Community

Eduard
Eduard

Posted on

I built a free SEO audit tool with 25+ features using Next.js 16, TypeScript, and AI — here's what I learned

I spent 2 months building AI SEO Copilot — a free SEO analyzer that checks 12 dimensions and generates AI recommendations. No signup, no paywall. Here's the technical breakdown.

What It Does

Paste any URL, get a full audit in 60 seconds. 12-dimension analysis:

  • Meta tags (title, description, OG tags, Twitter cards)
  • Heading structure (H1-H6 hierarchy)
  • Core Web Vitals (LCP, INP, CLS, FCP, TTFB)
  • Schema markup validation + suggestions
  • Content quality scoring
  • Internal & external link analysis
  • Image SEO (alt text, formats, lazy loading)
  • Redirect chain detection
  • Social media previews
  • E-E-A-T signals

Plus 25 additional tools: schema validator, meta tag analyzer, keyword clusters, rank tracker, content editor, competitor diff, broken link checker, and more.

The Stack

Layer Tech
Framework Next.js 16 (App Router, Turbopack)
Language TypeScript strict
Styling Tailwind CSS v4
Database Supabase
AI Gemini → Groq → Mistral (fallback chain)
State Zustand
Components shadcn/ui
Deploy Vercel

The Scan Pipeline

Every URL goes through this flow:

URL Input → SSRF Check → Fetch HTML → Parse → Score 12 Dimensions → AI Insights → Response
Enter fullscreen mode Exit fullscreen mode

The core scanner fetches raw HTML and extracts every SEO signal:

async scanURL(url: string): Promise<PageData> {
  const response = await fetch(url, {
    headers: { "User-Agent": "Mozilla/5.0 (compatible; AISEOCopilot/1.0)" },
    signal: AbortSignal.timeout(15000),
    redirect: "follow"
  });

  const html = await response.text();

  return {
    title: this.extractTitle(html),
    meta_description: this.extractMetaDescription(html),
    h1: this.extractH1(html),
    schema_markup: this.extractSchema(html),
    internal_links: this.countInternalLinks(html, url),
    external_links: this.countExternalLinks(html, url),
    images_without_alt: this.countImagesWithoutAlt(html),
    word_count: this.countWords(html),
    // ... 12 dimensions total
  };
}
Enter fullscreen mode Exit fullscreen mode

Each dimension gets scored, then weighted into an overall score. Title, H1, meta description, and schema each carry more weight than, say, image count.

The Biggest Problems I Solved

1. SSRF Protection

The biggest security risk — users can scan ANY URL. Someone could scan http://169.254.169.254 (AWS metadata) and leak server keys.

function isSafeURL(urlString: string): { ok: boolean; error?: string } {
  const url = new URL(urlString);
  const hostname = url.hostname.toLowerCase();

  // Block private IPs, localhost, cloud metadata
  if (/^(10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|127\.)/.test(hostname)) {
    return { ok: false, error: "Private IP blocked" };
  }

  if (["169.254.169.254", "metadata.google.internal"].includes(hostname)) {
    return { ok: false, error: "Cloud metadata blocked" };
  }

  return { ok: true };
}
Enter fullscreen mode Exit fullscreen mode

Every API endpoint that accepts a URL now runs through this before fetching.

2. AI Provider Fallback Chain

If Gemini fails, try Groq. If Groq fails, try Mistral. If all fail, generate offline insights from the scan data itself.

async function aiGenerate(prompt: string) {
  const providers = [
    { name: "gemini", fn: callGemini },
    { name: "groq", fn: callGroq },
    { name: "mistral", fn: callMistral }
  ];

  for (const provider of providers) {
    try {
      const result = await provider.fn(prompt);
      if (result.text.length > 0) return result;
    } catch {
      continue;
    }
  }

  // All failed — offline analysis
  return { text: generateOfflineInsights(prompt) };
}
Enter fullscreen mode Exit fullscreen mode

Users always get results, even when APIs are down.

3. AI Output Formatting

LLMs return markdown with **asterisks** everywhere. Users saw raw formatting in the UI.

function stripMarkdown(text: string): string {
  return text
    .replace(/\*\*([^*]+)\*\*/g, "$1")
    .replace(/\*([^*]+)\*/g, "$1")
    .replace(/^[-*]\s+/gm, "")
    .trim();
}
Enter fullscreen mode Exit fullscreen mode

Simple fix, huge UX improvement.

4. Header Link Explosion

The scanner counts every <a href> in the DOM. My header had both desktop mega menu AND mobile menu always rendered — just CSS-hidden. Scanner saw 90 internal links and flagged it as "Internal Link Overload."

// Before: Always in DOM (90 links)
<div className={mobileMenuOpen ? "visible" : "hidden"}>
  {tools.map(tool => <Link href={tool.href}>...</Link>)}
</div>

// After: Only mounts when open (34 links)
{mobileMenuOpen && (
  <div>{tools.map(tool => <Link href={tool.href}>...</Link>)}</div>
)}
Enter fullscreen mode Exit fullscreen mode

5. Rate Limiting in Serverless

Vercel serverless = each request is a new instance. In-memory rate limiting doesn't work across instances. Currently using in-memory solution. Planning Upstash Redis for distributed state.

Honest Results

28 days live. Google Search Console:

  • 2 clicks, 2,330 impressions
  • Position 76.8 (page 8)

The tool works well — tested against Ahrefs and Semrush, finds real issues. But without domain authority, Google doesn't care about features. Competing against DA 91 (Ahrefs) with DA ~5.

What's Next

  • WordPress plugin for easier distribution
  • Redis for proper rate limiting
  • Custom domain ($12/year — budget is tight)
  • More blog content (15+ articles and growing)

🟢 Try It

ai-seo-audit-seven.vercel.app — free, no signup, paste any URL.

Let's Build Together 🤝

I'm open to collaboration, strategic partnerships, or bringing in a co-owner/partner to scale this project, boost marketing, and take it to the next level.

Reach out to me on LinkedIn.


Built with Next.js 16, TypeScript, Supabase, and three AI providers. Open to feedback.

Top comments (0)