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:
- Add more auditors - Performance, accessibility, security headers
- Batch auditing - Audit entire sitemaps
- Historical tracking - Store results over time
- PDF reports - Generate shareable reports
- 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.
Try Katsau API
Extract metadata, generate link previews, and monitor URLs with our powerful API. Start free with 1,000 requests per month.