SEO Node.js Automation April 5, 2026

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 };
}
</code></pre>
      </section>

      <!-- Broken link checker -->
      <section id="broken-links">
        <h2>Broken Link Checker</h2>
        <pre><code class="language-javascript">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;
}
</code></pre>
      </section>

      <!-- Full site audit -->
      <section id="full-site-audit">
        <h2>Full Site Audit (Crawl + Audit)</h2>
        <pre><code class="language-javascript">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;
}
</code></pre>
      </section>

      <!-- SnapAPI for JS pages -->
      <section id="snapapi-audit">
        <h2>Auditing JS-Rendered Pages with SnapAPI</h2>
        <p>React/Vue/Angular pages return empty HTML to <code>axios.get()</code>. Use SnapAPI to get the rendered output before auditing:</p>
        <pre><code class="language-javascript">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
}
</code></pre>

        <div class="cta-box" style="background:linear-gradient(135deg,#1a1a2e,#16213e);border:1px solid #7c3aed33;border-radius:16px;padding:2rem;margin:3rem 0;text-align:center;">
          <h3 style="color:#fff;margin:0 0 .75rem;">Audit any page — including SPAs</h3>
          <p style="color:#a0aec0;margin:0 0 1.5rem;">SnapAPI renders JS pages and returns the HTML, or use /analyze for an AI-powered SEO assessment. 200 free requests/month.</p>
          <a href="https://snapapi.pics/register.html" style="display:inline-block;background:#7c3aed;color:#fff;padding:.75rem 2rem;border-radius:8px;text-decoration:none;font-weight:600;">Try SnapAPI Free →</a>
        </div>
      </section>
    </article>

    <aside class="sidebar" style="position:sticky;top:100px;align-self:start;">
      <div class="toc-card" style="background:#111118;border:1px solid #ffffff1a;border-radius:12px;padding:1.5rem;">
        <h4 style="color:#fff;margin:0 0 1rem;font-size:.95rem;text-transform:uppercase;letter-spacing:.05em;">Contents</h4>
        <nav>
          <ul style="list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:.5rem;">
            <li><a href="#core-auditor" style="color:#a0aec0;text-decoration:none;font-size:.9rem;">Core page auditor</a></li>
            <li><a href="#broken-links" style="color:#a0aec0;text-decoration:none;font-size:.9rem;">Broken link checker</a></li>
            <li><a href="#full-site-audit" style="color:#a0aec0;text-decoration:none;font-size:.9rem;">Full site audit</a></li>
            <li><a href="#snapapi-audit" style="color:#a0aec0;text-decoration:none;font-size:.9rem;">SnapAPI for JS pages</a></li>
          </ul>
        </nav>
      </div>
    </aside>
  </main>

  <footer style="border-top:1px solid #ffffff1a;padding:2rem;text-align:center;color:#4a5568;font-size:.875rem;margin-top:4rem;">
    <p>© 2026 SnapAPI · <a href="https://snapapi.pics" style="color:#7c3aed;">snapapi.pics</a> · Web Capture API</p>
  </footer>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-javascript.min.js"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-bash.min.js"></script>
  <script src="/app.db502c2b.js"></script>
  <script>
    const sections = document.querySelectorAll('section[id]');
    const tocLinks = document.querySelectorAll('.toc-card a');
    const observer = new IntersectionObserver(entries => {
      entries.forEach(e => {
        if (e.isIntersecting)
          tocLinks.forEach(a => a.style.color = a.getAttribute('href') === '#' + e.target.id ? '#7c3aed' : '#a0aec0');
      });
    }, { rootMargin: '-20% 0px -70% 0px' });
    sections.forEach(s => observer.observe(s));
  </script>
</body>
</html>