Build Your Own SEO Audit Tool with JavaScript: Complete Guide
Tutorial14 min read

Build Your Own SEO Audit Tool with JavaScript: Complete Guide

Learn to build a professional SEO audit tool that analyzes meta tags, headings, images, and more. Full source code with Next.js and real-time analysis.

Katsau

Katsau Team

December 21, 2025

Share:

SEO audit tools like Screaming Frog, Ahrefs, and Moz cost hundreds per month. What if you could build your own? In this tutorial, you'll create a professional SEO audit tool that analyzes any webpage for common issues.

What We're Building

A comprehensive SEO auditor that checks:

  • Meta tags - Title, description, Open Graph, Twitter Cards
  • Headings - H1 presence, heading hierarchy
  • Images - Alt text, file sizes, lazy loading
  • Links - Internal/external ratio, broken links
  • Performance - Page load indicators
  • Mobile - Viewport, responsive images
  • Structured Data - JSON-LD validation

Project Architecture

seo-auditor/
├── app/
│   ├── page.tsx           # Main audit interface
│   ├── api/
│   │   └── audit/
│   │       └── route.ts   # Audit API endpoint
│   └── layout.tsx
├── lib/
│   ├── auditors/
│   │   ├── meta.ts        # Meta tag checks
│   │   ├── headings.ts    # Heading analysis
│   │   ├── images.ts      # Image optimization
│   │   ├── links.ts       # Link analysis
│   │   └── structured-data.ts
│   ├── types.ts           # TypeScript types
│   └── scoring.ts         # Score calculation
└── components/
    ├── AuditForm.tsx
    ├── AuditResults.tsx
    └── IssueCard.tsx

Setup

npx create-next-app@latest seo-auditor --typescript --tailwind --app
cd seo-auditor
npm install zod lucide-react

Type Definitions

First, define our types:

// lib/types.ts

export type Severity = 'critical' | 'warning' | 'info' | 'success';

export interface AuditIssue {
  id: string;
  category: string;
  title: string;
  description: string;
  severity: Severity;
  details?: string;
  suggestion?: string;
}

export interface AuditResult {
  url: string;
  timestamp: number;
  score: number;
  issues: AuditIssue[];
  metadata: {
    title: string | null;
    description: string | null;
    ogImage: string | null;
    canonical: string | null;
  };
  stats: {
    totalIssues: number;
    critical: number;
    warnings: number;
    passed: number;
  };
}

export interface PageData {
  url: string;
  title: string | null;
  description: string | null;
  ogTitle: string | null;
  ogDescription: string | null;
  ogImage: string | null;
  ogType: string | null;
  twitterCard: string | null;
  twitterTitle: string | null;
  twitterDescription: string | null;
  twitterImage: string | null;
  canonical: string | null;
  robots: string | null;
  viewport: string | null;
  charset: string | null;
  lang: string | null;
  headings: {
    h1: string[];
    h2: string[];
    h3: string[];
    h4: string[];
    h5: string[];
    h6: string[];
  };
  images: Array<{
    src: string;
    alt: string | null;
    width: number | null;
    height: number | null;
    loading: string | null;
  }>;
  links: Array<{
    href: string;
    text: string;
    rel: string | null;
    isExternal: boolean;
  }>;
  structuredData: object[];
  wordCount: number;
}

Meta Tag Auditor

// lib/auditors/meta.ts

import { AuditIssue, PageData } from '../types';

export function auditMetaTags(data: PageData): AuditIssue[] {
  const issues: AuditIssue[] = [];

  // Title checks
  if (!data.title) {
    issues.push({
      id: 'meta-title-missing',
      category: 'Meta Tags',
      title: 'Missing page title',
      description: 'The page is missing a <title> tag.',
      severity: 'critical',
      suggestion: 'Add a unique, descriptive title tag to the page.',
    });
  } else {
    const titleLength = data.title.length;
    if (titleLength < 30) {
      issues.push({
        id: 'meta-title-short',
        category: 'Meta Tags',
        title: 'Title too short',
        description: `Title is ${titleLength} characters. Recommended: 50-60 characters.`,
        severity: 'warning',
        details: `Current title: "${data.title}"`,
        suggestion: 'Expand the title to include relevant keywords.',
      });
    } else if (titleLength > 60) {
      issues.push({
        id: 'meta-title-long',
        category: 'Meta Tags',
        title: 'Title too long',
        description: `Title is ${titleLength} characters. May be truncated in search results.`,
        severity: 'warning',
        details: `Current title: "${data.title}"`,
        suggestion: 'Shorten the title to under 60 characters.',
      });
    } else {
      issues.push({
        id: 'meta-title-good',
        category: 'Meta Tags',
        title: 'Title length is optimal',
        description: `Title is ${titleLength} characters.`,
        severity: 'success',
      });
    }
  }

  // Description checks
  if (!data.description) {
    issues.push({
      id: 'meta-desc-missing',
      category: 'Meta Tags',
      title: 'Missing meta description',
      description: 'The page is missing a meta description.',
      severity: 'critical',
      suggestion: 'Add a compelling meta description (150-160 characters).',
    });
  } else {
    const descLength = data.description.length;
    if (descLength < 120) {
      issues.push({
        id: 'meta-desc-short',
        category: 'Meta Tags',
        title: 'Meta description too short',
        description: `Description is ${descLength} characters. Recommended: 150-160 characters.`,
        severity: 'warning',
        suggestion: 'Expand the description with relevant information.',
      });
    } else if (descLength > 160) {
      issues.push({
        id: 'meta-desc-long',
        category: 'Meta Tags',
        title: 'Meta description too long',
        description: `Description is ${descLength} characters. May be truncated.`,
        severity: 'info',
        suggestion: 'Consider shortening to under 160 characters.',
      });
    } else {
      issues.push({
        id: 'meta-desc-good',
        category: 'Meta Tags',
        title: 'Meta description length is optimal',
        description: `Description is ${descLength} characters.`,
        severity: 'success',
      });
    }
  }

  // Canonical URL
  if (!data.canonical) {
    issues.push({
      id: 'meta-canonical-missing',
      category: 'Meta Tags',
      title: 'Missing canonical URL',
      description: 'No canonical URL is specified.',
      severity: 'warning',
      suggestion: 'Add a canonical URL to prevent duplicate content issues.',
    });
  } else {
    issues.push({
      id: 'meta-canonical-present',
      category: 'Meta Tags',
      title: 'Canonical URL present',
      description: 'Page has a canonical URL.',
      severity: 'success',
      details: data.canonical,
    });
  }

  // Viewport
  if (!data.viewport) {
    issues.push({
      id: 'meta-viewport-missing',
      category: 'Meta Tags',
      title: 'Missing viewport meta tag',
      description: 'The page may not be mobile-friendly.',
      severity: 'critical',
      suggestion: 'Add <meta name="viewport" content="width=device-width, initial-scale=1">',
    });
  }

  // Language
  if (!data.lang) {
    issues.push({
      id: 'meta-lang-missing',
      category: 'Meta Tags',
      title: 'Missing language attribute',
      description: 'The <html> tag is missing a lang attribute.',
      severity: 'warning',
      suggestion: 'Add lang="en" (or appropriate language) to the html tag.',
    });
  }

  return issues;
}

Open Graph Auditor

// lib/auditors/social.ts

import { AuditIssue, PageData } from '../types';

export function auditSocialTags(data: PageData): AuditIssue[] {
  const issues: AuditIssue[] = [];

  // Open Graph title
  if (!data.ogTitle) {
    issues.push({
      id: 'og-title-missing',
      category: 'Social Media',
      title: 'Missing og:title',
      description: 'Open Graph title is not set.',
      severity: 'warning',
      suggestion: 'Add <meta property="og:title" content="Your Title">',
    });
  }

  // Open Graph description
  if (!data.ogDescription) {
    issues.push({
      id: 'og-desc-missing',
      category: 'Social Media',
      title: 'Missing og:description',
      description: 'Open Graph description is not set.',
      severity: 'warning',
      suggestion: 'Add <meta property="og:description" content="Your description">',
    });
  }

  // Open Graph image
  if (!data.ogImage) {
    issues.push({
      id: 'og-image-missing',
      category: 'Social Media',
      title: 'Missing og:image',
      description: 'Social shares will have no preview image.',
      severity: 'critical',
      suggestion: 'Add <meta property="og:image" content="https://..."> (1200x630 recommended)',
    });
  } else {
    // Check if image URL is absolute
    if (!data.ogImage.startsWith('http')) {
      issues.push({
        id: 'og-image-relative',
        category: 'Social Media',
        title: 'og:image uses relative URL',
        description: 'Open Graph image URL must be absolute.',
        severity: 'critical',
        details: `Current: ${data.ogImage}`,
        suggestion: 'Use full URL: https://example.com/image.jpg',
      });
    }
  }

  // Twitter Card
  if (!data.twitterCard) {
    issues.push({
      id: 'twitter-card-missing',
      category: 'Social Media',
      title: 'Missing twitter:card',
      description: 'Twitter card type is not specified.',
      severity: 'warning',
      suggestion: 'Add <meta name="twitter:card" content="summary_large_image">',
    });
  }

  // Check if OG and standard meta match
  if (data.title && data.ogTitle && data.title !== data.ogTitle) {
    issues.push({
      id: 'og-title-mismatch',
      category: 'Social Media',
      title: 'Title and og:title differ',
      description: 'Page title and Open Graph title are different.',
      severity: 'info',
      details: `Title: "${data.title}" | og:title: "${data.ogTitle}"`,
    });
  }

  // All social tags present
  if (data.ogTitle && data.ogDescription && data.ogImage && data.twitterCard) {
    issues.push({
      id: 'social-complete',
      category: 'Social Media',
      title: 'Social media tags complete',
      description: 'All essential social media tags are present.',
      severity: 'success',
    });
  }

  return issues;
}

Headings Auditor

// lib/auditors/headings.ts

import { AuditIssue, PageData } from '../types';

export function auditHeadings(data: PageData): AuditIssue[] {
  const issues: AuditIssue[] = [];
  const { headings } = data;

  // H1 checks
  if (headings.h1.length === 0) {
    issues.push({
      id: 'heading-h1-missing',
      category: 'Headings',
      title: 'Missing H1 tag',
      description: 'The page has no H1 heading.',
      severity: 'critical',
      suggestion: 'Add a single, descriptive H1 tag to the page.',
    });
  } else if (headings.h1.length > 1) {
    issues.push({
      id: 'heading-h1-multiple',
      category: 'Headings',
      title: 'Multiple H1 tags',
      description: `Found ${headings.h1.length} H1 tags. Best practice is one H1 per page.`,
      severity: 'warning',
      details: headings.h1.map((h, i) => `${i + 1}. "${h}"`).join('\n'),
      suggestion: 'Use only one H1 for the main page title.',
    });
  } else {
    const h1Length = headings.h1[0].length;
    if (h1Length > 70) {
      issues.push({
        id: 'heading-h1-long',
        category: 'Headings',
        title: 'H1 is too long',
        description: `H1 is ${h1Length} characters. Consider shortening.`,
        severity: 'info',
        details: headings.h1[0],
      });
    } else {
      issues.push({
        id: 'heading-h1-good',
        category: 'Headings',
        title: 'H1 tag present',
        description: 'Page has a single H1 heading.',
        severity: 'success',
        details: headings.h1[0],
      });
    }
  }

  // Heading hierarchy
  const hasH2 = headings.h2.length > 0;
  const hasH3 = headings.h3.length > 0;
  const hasH4 = headings.h4.length > 0;

  if (hasH3 && !hasH2) {
    issues.push({
      id: 'heading-skip-h2',
      category: 'Headings',
      title: 'Heading level skipped',
      description: 'Page has H3 but no H2. This breaks heading hierarchy.',
      severity: 'warning',
      suggestion: 'Ensure headings follow a logical hierarchy (H1 → H2 → H3).',
    });
  }

  if (hasH4 && !hasH3) {
    issues.push({
      id: 'heading-skip-h3',
      category: 'Headings',
      title: 'Heading level skipped',
      description: 'Page has H4 but no H3.',
      severity: 'warning',
    });
  }

  // Total heading count
  const totalHeadings =
    headings.h1.length +
    headings.h2.length +
    headings.h3.length +
    headings.h4.length +
    headings.h5.length +
    headings.h6.length;

  if (totalHeadings === 0) {
    issues.push({
      id: 'heading-none',
      category: 'Headings',
      title: 'No headings found',
      description: 'The page has no heading tags.',
      severity: 'critical',
    });
  } else {
    issues.push({
      id: 'heading-count',
      category: 'Headings',
      title: 'Heading structure',
      description: `Found ${totalHeadings} headings.`,
      severity: 'info',
      details: `H1: ${headings.h1.length}, H2: ${headings.h2.length}, H3: ${headings.h3.length}, H4: ${headings.h4.length}`,
    });
  }

  return issues;
}

Images Auditor

// lib/auditors/images.ts

import { AuditIssue, PageData } from '../types';

export function auditImages(data: PageData): AuditIssue[] {
  const issues: AuditIssue[] = [];
  const { images } = data;

  if (images.length === 0) {
    issues.push({
      id: 'images-none',
      category: 'Images',
      title: 'No images found',
      description: 'The page has no images.',
      severity: 'info',
    });
    return issues;
  }

  // Check for missing alt text
  const missingAlt = images.filter((img) => !img.alt || img.alt.trim() === '');
  if (missingAlt.length > 0) {
    issues.push({
      id: 'images-alt-missing',
      category: 'Images',
      title: 'Images missing alt text',
      description: `${missingAlt.length} of ${images.length} images have no alt text.`,
      severity: missingAlt.length > images.length / 2 ? 'critical' : 'warning',
      details: missingAlt
        .slice(0, 5)
        .map((img) => img.src)
        .join('\n'),
      suggestion: 'Add descriptive alt text to all images for accessibility.',
    });
  } else {
    issues.push({
      id: 'images-alt-good',
      category: 'Images',
      title: 'All images have alt text',
      description: `All ${images.length} images have alt attributes.`,
      severity: 'success',
    });
  }

  // Check for lazy loading
  const notLazy = images.filter((img) => img.loading !== 'lazy');
  if (notLazy.length > 3) {
    // Exclude first few images that should load eagerly
    issues.push({
      id: 'images-lazy-missing',
      category: 'Images',
      title: 'Images not lazy-loaded',
      description: `${notLazy.length} images don't use lazy loading.`,
      severity: 'info',
      suggestion: 'Add loading="lazy" to below-the-fold images.',
    });
  }

  // Check for missing dimensions
  const noDimensions = images.filter((img) => !img.width || !img.height);
  if (noDimensions.length > 0) {
    issues.push({
      id: 'images-dimensions-missing',
      category: 'Images',
      title: 'Images missing dimensions',
      description: `${noDimensions.length} images don't specify width/height.`,
      severity: 'warning',
      suggestion: 'Add width and height attributes to prevent layout shift.',
    });
  }

  // Summary
  issues.push({
    id: 'images-count',
    category: 'Images',
    title: 'Image analysis',
    description: `Found ${images.length} images on the page.`,
    severity: 'info',
  });

  return issues;
}

API Route

// app/api/audit/route.ts

import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { auditMetaTags } from '@/lib/auditors/meta';
import { auditSocialTags } from '@/lib/auditors/social';
import { auditHeadings } from '@/lib/auditors/headings';
import { auditImages } from '@/lib/auditors/images';
import { AuditResult, PageData } from '@/lib/types';

const requestSchema = z.object({
  url: z.string().url(),
});

export async function POST(request: NextRequest) {
  try {
    const body = await request.json();
    const { url } = requestSchema.parse(body);

    // Fetch page data from metadata API
    const response = await fetch(
      `https://api.katsau.com/v1/analyze?url=${encodeURIComponent(url)}`,
      {
        headers: {
          'Authorization': `Bearer ${process.env.KATSAU_API_KEY}`,
        },
      }
    );

    if (!response.ok) {
      throw new Error('Failed to fetch page data');
    }

    const { data } = await response.json();

    // Convert API response to PageData
    const pageData: PageData = {
      url,
      title: data.title,
      description: data.description,
      ogTitle: data.ogTitle,
      ogDescription: data.ogDescription,
      ogImage: data.image,
      ogType: data.ogType,
      twitterCard: data.twitterCard,
      twitterTitle: data.twitterTitle,
      twitterDescription: data.twitterDescription,
      twitterImage: data.twitterImage,
      canonical: data.canonical,
      robots: data.robots,
      viewport: data.viewport,
      charset: data.charset,
      lang: data.lang,
      headings: data.headings || { h1: [], h2: [], h3: [], h4: [], h5: [], h6: [] },
      images: data.images || [],
      links: data.links || [],
      structuredData: data.structuredData || [],
      wordCount: data.wordCount || 0,
    };

    // Run all auditors
    const issues = [
      ...auditMetaTags(pageData),
      ...auditSocialTags(pageData),
      ...auditHeadings(pageData),
      ...auditImages(pageData),
    ];

    // Calculate score
    const critical = issues.filter((i) => i.severity === 'critical').length;
    const warnings = issues.filter((i) => i.severity === 'warning').length;
    const passed = issues.filter((i) => i.severity === 'success').length;

    const maxScore = 100;
    const score = Math.max(
      0,
      maxScore - critical * 15 - warnings * 5
    );

    const result: AuditResult = {
      url,
      timestamp: Date.now(),
      score,
      issues,
      metadata: {
        title: pageData.title,
        description: pageData.description,
        ogImage: pageData.ogImage,
        canonical: pageData.canonical,
      },
      stats: {
        totalIssues: issues.length,
        critical,
        warnings,
        passed,
      },
    };

    return NextResponse.json(result);
  } catch (error) {
    console.error('Audit error:', error);
    return NextResponse.json(
      { error: 'Failed to audit URL' },
      { status: 500 }
    );
  }
}

Frontend Components

Audit Form

// components/AuditForm.tsx
'use client';

import { useState } from 'react';
import { Search, Loader2 } from 'lucide-react';

interface AuditFormProps {
  onAudit: (url: string) => void;
  isLoading: boolean;
}

export function AuditForm({ onAudit, isLoading }: AuditFormProps) {
  const [url, setUrl] = useState('');

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (url.trim()) {
      // Add https if missing
      let finalUrl = url.trim();
      if (!finalUrl.startsWith('http')) {
        finalUrl = `https://${finalUrl}`;
      }
      onAudit(finalUrl);
    }
  };

  return (
    <form onSubmit={handleSubmit} className="w-full max-w-2xl">
      <div className="flex gap-2">
        <div className="relative flex-1">
          <Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
          <input
            type="text"
            value={url}
            onChange={(e) => setUrl(e.target.value)}
            placeholder="Enter URL to audit (e.g., example.com)"
            className="w-full pl-12 pr-4 py-4 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-lg"
            disabled={isLoading}
          />
        </div>
        <button
          type="submit"
          disabled={!url.trim() || isLoading}
          className="px-8 py-4 bg-blue-600 text-white font-semibold rounded-xl hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center gap-2"
        >
          {isLoading ? (
            <>
              <Loader2 className="w-5 h-5 animate-spin" />
              Auditing...
            </>
          ) : (
            'Audit'
          )}
        </button>
      </div>
    </form>
  );
}

Score Display

// components/ScoreDisplay.tsx

interface ScoreDisplayProps {
  score: number;
}

export function ScoreDisplay({ score }: ScoreDisplayProps) {
  const getColor = () => {
    if (score >= 90) return 'text-green-600';
    if (score >= 70) return 'text-yellow-600';
    if (score >= 50) return 'text-orange-600';
    return 'text-red-600';
  };

  const getGrade = () => {
    if (score >= 90) return 'A';
    if (score >= 80) return 'B';
    if (score >= 70) return 'C';
    if (score >= 60) return 'D';
    return 'F';
  };

  return (
    <div className="text-center">
      <div className={`text-8xl font-bold ${getColor()}`}>{score}</div>
      <div className="text-2xl text-gray-500 mt-2">
        Grade: <span className={`font-bold ${getColor()}`}>{getGrade()}</span>
      </div>
    </div>
  );
}

Adding More Auditors

Extend the tool with additional checks:

Links Auditor

// lib/auditors/links.ts

export function auditLinks(data: PageData): AuditIssue[] {
  const issues: AuditIssue[] = [];
  const { links } = data;

  const internal = links.filter((l) => !l.isExternal);
  const external = links.filter((l) => l.isExternal);
  const nofollow = external.filter((l) => l.rel?.includes('nofollow'));

  issues.push({
    id: 'links-summary',
    category: 'Links',
    title: 'Link analysis',
    description: `Found ${links.length} links (${internal.length} internal, ${external.length} external)`,
    severity: 'info',
  });

  // Check for empty links
  const emptyText = links.filter((l) => !l.text.trim());
  if (emptyText.length > 0) {
    issues.push({
      id: 'links-empty-text',
      category: 'Links',
      title: 'Links with no anchor text',
      description: `${emptyText.length} links have empty anchor text.`,
      severity: 'warning',
      suggestion: 'Add descriptive anchor text to all links.',
    });
  }

  return issues;
}

Structured Data Auditor

// lib/auditors/structured-data.ts

export function auditStructuredData(data: PageData): AuditIssue[] {
  const issues: AuditIssue[] = [];
  const { structuredData } = data;

  if (structuredData.length === 0) {
    issues.push({
      id: 'structured-data-missing',
      category: 'Structured Data',
      title: 'No structured data found',
      description: 'The page has no JSON-LD structured data.',
      severity: 'warning',
      suggestion: 'Add Schema.org structured data for rich search results.',
    });
  } else {
    issues.push({
      id: 'structured-data-present',
      category: 'Structured Data',
      title: 'Structured data found',
      description: `Found ${structuredData.length} JSON-LD blocks.`,
      severity: 'success',
      details: structuredData.map((s: any) => s['@type']).join(', '),
    });
  }

  return issues;
}

Conclusion

You've built a professional SEO audit tool! To make it production-ready:

  1. Add more auditors - Performance, accessibility, security headers
  2. Batch auditing - Audit entire sitemaps
  3. Historical tracking - Store results over time
  4. PDF reports - Generate shareable reports
  5. Scheduling - Automated periodic audits

The key insight: extracting page data is the hard part. By using a metadata API, you can focus on building audit logic rather than parsing infrastructure.


Need reliable page analysis for your SEO tool? Try Katsau's analyze endpoint — get all page data in one API call.

Ready to build?

Try Katsau API

Extract metadata, generate link previews, and monitor URLs with our powerful API. Start free with 1,000 requests per month.