Build an SEO Audit Tool in Node.js (2026)
Manually checking SEO issues across dozens of pages is slow and error-prone. A custom Node.js audit tool can check every page on your site in minutes — meta tags, headings, image alt text, broken links, canonical tags, structured data, and more. This guide builds a complete auditor with Cheerio for parsing and SnapAPI for rendered pages.
Core Page Auditor
npm install axios cheerio chalk csv-stringify
const axios = require('axios');
const cheerio = require('cheerio');
const HEADERS = {
'User-Agent': 'Mozilla/5.0 (compatible; SEOAuditBot/1.0)',
'Accept': 'text/html,application/xhtml+xml;q=0.9,*/*;q=0.8',
};
/**
* Audit a single page and return structured findings.
*/
async function auditPage(url) {
const issues = [];
const warnings = [];
const info = {};
let html, status, responseTime;
const start = Date.now();
try {
const resp = await axios.get(url, { headers: HEADERS, timeout: 15000, maxRedirects: 5 });
html = resp.data;
status = resp.status;
responseTime = Date.now() - start;
} catch (err) {
return { url, status: err.response?.status ?? 0, issues: [`Fetch error: ${err.message}`], warnings: [], info: {} };
}
const $ = cheerio.load(html);
// --- Title tag ---
const title = $('title').text().trim();
info.title = title;
if (!title) issues.push('Missing tag');
else if (title.length < 30) warnings.push(`Title too short (${title.length} chars) — aim for 50–60`);
else if (title.length > 60) warnings.push(`Title too long (${title.length} chars) — aim for 50–60`);
// --- Meta description ---
const metaDesc = $('meta[name="description"]').attr('content')?.trim() ?? '';
info.metaDesc = metaDesc;
if (!metaDesc) issues.push('Missing meta description');
else if (metaDesc.length < 100) warnings.push(`Meta description short (${metaDesc.length} chars) — aim for 120–160`);
else if (metaDesc.length > 160) warnings.push(`Meta description long (${metaDesc.length} chars) — aim for 120–160`);
// --- H1 ---
const h1s = $('h1').map((_, el) => $(el).text().trim()).get();
info.h1Count = h1s.length;
if (h1s.length === 0) issues.push('Missing H1 tag');
else if (h1s.length > 1) warnings.push(`Multiple H1 tags (${h1s.length}) — use only one`);
// --- Canonical ---
const canonical = $('link[rel="canonical"]').attr('href');
info.canonical = canonical ?? null;
if (!canonical) warnings.push('No canonical tag — add one to avoid duplicate content issues');
// --- Images without alt ---
const imgs = $('img');
const missingAlt = imgs.filter((_, el) => !$(el).attr('alt')).length;
info.imgCount = imgs.length;
info.missingAlt = missingAlt;
if (missingAlt > 0) warnings.push(`${missingAlt}/${imgs.length} images missing alt attribute`);
// --- Structured data ---
const ldJson = $('script[type="application/ld+json"]').map((_, el) => {
try { return JSON.parse($(el).html()); } catch { return null; }
}).get().filter(Boolean);
info.structuredDataTypes = ldJson.map(d => d['@type']).filter(Boolean);
// --- Word count ---
info.wordCount = $('body').text().split(/\s+/).filter(w => w.length > 2).length;
if (info.wordCount < 300) warnings.push(`Low word count (${info.wordCount} words) — aim for 600+ for content pages`);
// --- Response time ---
info.responseTimeMs = responseTime;
if (responseTime > 3000) warnings.push(`Slow response time (${responseTime}ms) — aim for under 2000ms`);
return { url, status, issues, warnings, info };
}
Broken Link Checker
const { URL } = require('url');
async function checkLinks(html, pageUrl) {
const $ = cheerio.load(html);
const base = new URL(pageUrl);
const links = $('a[href]').map((_, el) => $(el).attr('href')).get()
.map(href => { try { return new URL(href, base).href; } catch { return null; } })
.filter(Boolean);
const unique = [...new Set(links)];
const results = { total: unique.length, broken: [], redirects: [] };
await Promise.all(unique.slice(0, 50).map(async link => { // limit to 50
try {
const resp = await axios.head(link, {
headers: HEADERS, timeout: 8000, maxRedirects: 0, validateStatus: () => true
});
if (resp.status >= 400) results.broken.push({ url: link, status: resp.status });
else if (resp.status >= 300 && resp.status < 400) results.redirects.push({ url: link, status: resp.status, to: resp.headers.location });
} catch (err) {
results.broken.push({ url: link, status: 0, error: err.message });
}
}));
return results;
}
Full Site Audit (Crawl + Audit)
const PQueue = require('p-queue');
async function auditSite(seedUrl, maxPages = 100) {
const visited = new Set();
const queue = [seedUrl];
const reports = [];
const pq = new PQueue({ concurrency: 3, interval: 1000, intervalCap: 3 });
while (queue.length && reports.length < maxPages) {
const url = queue.shift();
if (visited.has(url)) continue;
visited.add(url);
const report = await pq.add(() => auditPage(url));
reports.push(report);
// Discover internal links from the page
try {
const { data } = await axios.get(url, { headers: HEADERS, timeout: 10000 });
const $ = cheerio.load(data);
const base = new URL(url);
$('a[href]').each((_, el) => {
try {
const href = new URL($(el).attr('href'), base);
if (href.hostname === base.hostname && !visited.has(href.href)) {
href.hash = ''; href.search = '';
queue.push(href.href);
}
} catch {}
});
} catch {}
console.log(`[${reports.length}/${maxPages}] ${url} — ${report.issues.length} issues, ${report.warnings.length} warnings`);
}
return reports;
}
Auditing JS-Rendered Pages with SnapAPI
React/Vue/Angular pages return empty HTML to axios.get(). Use SnapAPI to get the rendered output before auditing:
async function auditRenderedPage(url) {
const { data } = await axios.post('https://api.snapapi.pics/v1/scrape', {
url,
waitFor: 'networkidle',
blockAds: true
}, { headers: { 'X-Api-Key': process.env.SNAPAPI_KEY } });
// Swap the rendered HTML into our existing auditor
const $ = cheerio.load(data.html);
// ... run the same checks from auditPage()
// title, metaDesc, h1s, canonical, images, word count etc.
return auditPage(url); // or pass rendered HTML directly
}
// AI-powered SEO summary
async function aiSeoAnalysis(url) {
const { data } = await axios.post('https://api.snapapi.pics/v1/analyze', {
url,
prompt: 'Analyze this page for SEO. List: title tag quality, meta description quality, heading structure, content length assessment, internal linking quality, and structured data presence. Be specific and actionable.',
stealth: false
}, { headers: { 'X-Api-Key': process.env.SNAPAPI_KEY } });
return data.result; // AI-generated SEO analysis
}
Audit any page — including SPAs
SnapAPI renders JS pages and returns the HTML, or use /analyze for an AI-powered SEO assessment. 200 free requests/month.
Try SnapAPI Free →