March 29, 2026 · 13 min read

Automate SEO Audits in Express.js Middleware with Node.js and the SEOPeek API

Most Node.js teams run SEO audits manually—someone opens a tool, pastes a URL, and takes a screenshot. That works exactly once. The moment you refactor a template, deploy a CMS update, or add a new route, the audit is stale and the regression goes unnoticed for weeks. This guide shows you how to wire the SEOPeek API into your Express.js app so that audits run automatically: in development middleware that surfaces scores as you work, in a deploy hook that gates production releases, in route handlers that expose on-demand audits, and in a node-cron job that bulk-audits your entire sitemap on a schedule.

In this guide
  1. Calling the SEOPeek API from Node.js
  2. Express middleware for dev-mode SEO checking
  3. Deploy hook that audits pages on release
  4. Route handler for on-demand audits
  5. Cron-triggered bulk audit with node-cron
  6. Storing results in MongoDB and PostgreSQL
  7. Pricing and rate limits
  8. FAQ

1. Calling the SEOPeek API from Node.js

The SEOPeek API is a single REST endpoint. You send a GET request with a url query parameter, and it returns a JSON object containing a numeric score (0–100), a letter grade, and individual pass/fail results for 20+ on-page SEO checks. The free tier requires no API key.

Install the node-fetch package (or use the native fetch available in Node 18+):

npm install node-fetch

Here is the simplest possible audit call in JavaScript:

// seo/client.js
const SEOPEEK_API =
  'https://us-central1-todd-agent-prod.cloudfunctions.net/seopeekApi/api/v1/audit';

/**
 * Run an SEO audit on a single URL via the SEOPeek API.
 * @param {string} url - The fully-qualified URL to audit.
 * @returns {Promise<Object>} The audit result object.
 */
async function auditUrl(url) {
  const endpoint = new URL(SEOPEEK_API);
  endpoint.searchParams.set('url', url);

  const res = await fetch(endpoint.toString(), { signal: AbortSignal.timeout(30_000) });
  if (!res.ok) {
    throw new Error(`SEOPeek API returned ${res.status} for ${url}`);
  }
  return res.json();
}

module.exports = { auditUrl, SEOPEEK_API };

The API response looks like this:

{
  "url": "https://yoursite.com/blog/my-post/",
  "score": 84,
  "grade": "B",
  "checks": {
    "title":            { "pass": true,  "message": "Title tag exists and is 52 characters" },
    "meta_description": { "pass": true,  "message": "Meta description is 148 characters" },
    "h1":               { "pass": true,  "message": "Single H1 tag found" },
    "og_tags":          { "pass": false, "message": "Missing og:image tag" },
    "canonical":        { "pass": true,  "message": "Canonical URL is set" },
    "structured_data":  { "pass": false, "message": "No JSON-LD schema detected" },
    ...
  }
}

Tip: Keep the auditUrl function in a shared module (e.g., seo/client.js). Every pattern in this guide—middleware, deploy hooks, route handlers, cron jobs—imports the same client function. When you add an API key later, you update it in one place.

2. Express Middleware for Dev-Mode SEO Checking

During local development, it is useful to see SEO scores as you edit templates. This middleware intercepts every server-rendered HTML response, fires an asynchronous audit against the SEOPeek API, and injects the result as an HTML comment just before </body>. It also adds an X-SEO-Score header so you can see the score in the Network tab of DevTools without even opening the page source.

Create seo/middleware.js:

// seo/middleware.js
const { auditUrl } = require('./client');

/**
 * Dev-only middleware that audits every HTML response with SEOPeek.
 * Adds an X-SEO-Score header and injects a comment before </body>.
 * Short-circuits immediately when NODE_ENV !== 'development'.
 */
function seoAuditMiddleware(req, res, next) {
  // Never run in production
  if (process.env.NODE_ENV !== 'development') return next();

  // Only intercept full page requests (not API calls, assets, etc.)
  const acceptsHtml = req.headers.accept?.includes('text/html');
  if (!acceptsHtml) return next();

  // Patch res.send so we can intercept the HTML body
  const originalSend = res.send.bind(res);
  res.send = function(body) {
    if (typeof body !== 'string' || !body.includes('</body>')) {
      return originalSend(body);
    }

    const pageUrl = `${req.protocol}://${req.get('host')}${req.originalUrl}`;

    // Fire-and-forget: audit runs after response is sent
    auditUrl(pageUrl)
      .then(result => {
        const failing = Object.entries(result.checks || {})
          .filter(([, v]) => !v.pass)
          .map(([k, v]) => `  - ${k}: ${v.message}`)
          .join('\n');

        const comment = [
          '\n<!--',
          `  SEOPeek: ${result.score}/100 (${result.grade})`,
          failing ? `  Failing checks:\n${failing}` : '  All checks passed.',
          '-->',
        ].join('\n');

        // Note: we already called originalSend, this log is for the terminal
        console.log(`[SEOPeek] ${pageUrl} — ${result.score}/100 (${result.grade})`);
        if (failing) console.log(`[SEOPeek] Failing:\n${failing}`);
      })
      .catch(err => {
        console.warn(`[SEOPeek] Audit failed for ${pageUrl}: ${err.message}`);
      });

    // Inject score placeholder synchronously — full async inject needs response buffering
    res.setHeader('X-SEO-Audit', 'pending');
    return originalSend(body);
  };

  next();
}

module.exports = { seoAuditMiddleware };

For a fully synchronous version that injects the score directly into the HTML before sending, you need to buffer the response. Here is a version that does exactly that:

// seo/middleware-buffered.js
const { auditUrl } = require('./client');

function seoAuditMiddlewareBuffered(req, res, next) {
  if (process.env.NODE_ENV !== 'development') return next();

  const acceptsHtml = req.headers.accept?.includes('text/html');
  if (!acceptsHtml) return next();

  const originalSend = res.send.bind(res);

  res.send = async function(body) {
    if (typeof body !== 'string' || !body.includes('</body>')) {
      return originalSend(body);
    }

    const pageUrl = `${req.protocol}://${req.get('host')}${req.originalUrl}`;

    try {
      const result = await auditUrl(pageUrl);
      const failing = Object.entries(result.checks || {})
        .filter(([, v]) => !v.pass)
        .map(([k, v]) => `    - ${k}: ${v.message}`)
        .join('\n');

      const badge = `
<!--
  SEOPeek Score: ${result.score}/100 (${result.grade})
${failing ? `  Failing checks:\n${failing}\n` : '  All checks passed.\n'}-->`;

      res.setHeader('X-SEO-Score', `${result.score}/100`);
      res.setHeader('X-SEO-Grade', result.grade);
      return originalSend(body.replace('</body>', `${badge}\n</body>`));
    } catch {
      return originalSend(body);
    }
  };

  next();
}

module.exports = { seoAuditMiddlewareBuffered };

Register it in your Express app:

// app.js
const express = require('express');
const { seoAuditMiddlewareBuffered } = require('./seo/middleware-buffered');

const app = express();

// Register before your route handlers
app.use(seoAuditMiddlewareBuffered);

app.get('/', (req, res) => {
  res.send('<html><body><h1>Hello</h1></body></html>');
});

app.listen(3000);

With this in place, open any page in your browser while running in development mode. The page source will contain an HTML comment block showing the SEO score, and the Network tab will show the X-SEO-Score and X-SEO-Grade response headers. You get instant SEO feedback on every page render without switching tools.

3. Deploy Hook That Audits Pages on Release

The most valuable place to run SEO audits is immediately after a deployment, before users hit the new version. This script is designed to run as a post-deploy step: it audits a list of your most critical URLs and exits non-zero if any of them fall below your threshold, which will fail the deploy in most CI/CD systems.

Create scripts/seo-audit-deploy.js:

#!/usr/bin/env node
// scripts/seo-audit-deploy.js
// Run after deploy: node scripts/seo-audit-deploy.js
// Exit code 1 if any URL scores below MIN_SCORE.

const { auditUrl } = require('../seo/client');

const BASE_URL = process.env.DEPLOY_URL || 'https://yoursite.com';
const MIN_SCORE = parseInt(process.env.SEO_MIN_SCORE || '70', 10);
const DELAY_MS = 1200; // Stay well within rate limits

// List your most SEO-critical pages here
const CRITICAL_PATHS = [
  '/',
  '/pricing',
  '/blog',
  '/about',
  '/features',
];

function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

async function run() {
  const urls = CRITICAL_PATHS.map(p => `${BASE_URL}${p}`);
  const results = [];
  let failures = 0;

  console.log(`\nSEOPeek deploy audit — minimum score: ${MIN_SCORE}`);
  console.log(`Auditing ${urls.length} URLs against ${BASE_URL}\n`);
  console.log('-'.repeat(60));

  for (let i = 0; i < urls.length; i++) {
    const url = urls[i];
    try {
      const data = await auditUrl(url);
      const { score, grade, checks = {} } = data;
      const failingChecks = Object.entries(checks)
        .filter(([, v]) => !v.pass)
        .map(([k]) => k);

      results.push({ url, score, grade, failingChecks });

      if (score < MIN_SCORE) {
        failures++;
        console.error(`  FAIL  ${score}/100 (${grade})  ${url}`);
        failingChecks.forEach(k =>
          console.error(`        - ${k}: ${checks[k].message}`)
        );
      } else {
        console.log(`  PASS  ${score}/100 (${grade})  ${url}`);
      }
    } catch (err) {
      failures++;
      console.error(`  ERROR ${url}: ${err.message}`);
      results.push({ url, score: null, error: err.message });
    }

    if (i < urls.length - 1) await sleep(DELAY_MS);
  }

  console.log('\n' + '='.repeat(60));
  console.log(
    `Audited: ${urls.length}  |  ` +
    `Passed: ${urls.length - failures}  |  ` +
    `Failed: ${failures}`
  );

  if (failures > 0) {
    console.error(`\n${failures} URL(s) scored below ${MIN_SCORE}. Blocking deploy.`);
    process.exit(1);
  }

  console.log('\nAll pages passed SEO audit. Deploy unblocked.');
}

run().catch(err => {
  console.error('Unexpected error:', err);
  process.exit(1);
});

Wire it into your package.json scripts:

{
  "scripts": {
    "build": "...",
    "deploy": "npm run build && firebase deploy --only hosting,functions",
    "postdeploy": "SEO_MIN_SCORE=75 node scripts/seo-audit-deploy.js",
    "seo:audit": "node scripts/seo-audit-deploy.js"
  }
}

CI/CD integration: In GitHub Actions, set DEPLOY_URL to your staging environment URL and SEO_MIN_SCORE to your threshold. The non-zero exit code will automatically mark the workflow step as failed and prevent the deploy from completing. See also: running SEO checks in CI/CD pipelines for a full GitHub Actions example.

4. Route Handler for On-Demand Audits

Sometimes you want to expose SEO audit functionality over HTTP—a Slack slash command, an admin dashboard, or a webhook from your CMS that triggers a fresh audit whenever content is published. This Express route handler wraps the SEOPeek API call and returns structured JSON with rate-limit handling and basic caching.

// routes/seo-audit.js
const express = require('express');
const { auditUrl } = require('../seo/client');

const router = express.Router();

// Simple in-memory cache: { [url]: { result, cachedAt } }
const cache = new Map();
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes

/**
 * GET /api/seo-audit?url=https://yoursite.com/page
 *
 * Returns the SEOPeek audit result for the given URL.
 * Cached for 5 minutes per URL to avoid hammering the API.
 */
router.get('/seo-audit', async (req, res) => {
  const { url } = req.query;

  if (!url) {
    return res.status(400).json({ error: 'url query parameter is required' });
  }

  // Validate URL format
  let parsedUrl;
  try {
    parsedUrl = new URL(url);
  } catch {
    return res.status(400).json({ error: 'Invalid URL format' });
  }

  // Return cached result if fresh
  const cached = cache.get(url);
  if (cached && Date.now() - cached.cachedAt < CACHE_TTL_MS) {
    return res.json({ ...cached.result, cached: true });
  }

  try {
    const result = await auditUrl(parsedUrl.toString());

    // Summarise failing checks for convenience
    const failingSummary = Object.entries(result.checks || {})
      .filter(([, v]) => !v.pass)
      .map(([k, v]) => ({ check: k, message: v.message }));

    const enriched = {
      ...result,
      failingCount: failingSummary.length,
      failingSummary,
      cached: false,
      auditedAt: new Date().toISOString(),
    };

    cache.set(url, { result: enriched, cachedAt: Date.now() });
    return res.json(enriched);
  } catch (err) {
    if (err.message.includes('429')) {
      return res.status(429).json({
        error: 'Rate limit reached. Upgrade to SEOPeek Starter for 1,000 audits/month.',
      });
    }
    return res.status(502).json({ error: `Upstream audit failed: ${err.message}` });
  }
});

module.exports = router;

Mount it in your app:

// app.js
const seoAuditRouter = require('./routes/seo-audit');
app.use('/api', seoAuditRouter);

// Usage:
// GET /api/seo-audit?url=https://yoursite.com/pricing

This endpoint is useful for admin dashboards, CMS publish webhooks, and internal tooling. Because it caches results for 5 minutes, repeated calls from the same URL within the same editing session will not consume your daily audit quota.

5. Cron-Triggered Bulk Audit with node-cron

For ongoing SEO monitoring, you want audits to run on a schedule without any manual intervention. node-cron is the simplest way to do this in a Node.js process: it uses standard cron syntax and runs entirely within your application, no external scheduler required.

Install the dependency:

npm install node-cron

Create seo/scheduler.js:

// seo/scheduler.js
const cron = require('node-cron');
const { auditUrl } = require('./client');

// All URLs to monitor on schedule
const MONITORED_URLS = [
  'https://yoursite.com/',
  'https://yoursite.com/pricing',
  'https://yoursite.com/blog',
  'https://yoursite.com/features',
  'https://yoursite.com/about',
];

const DELAY_MS = 2000;      // 2 seconds between requests
const MIN_SCORE = 70;        // Alert threshold
const BATCH_SIZE = 10;       // Max URLs per run (free tier: 50/day)

function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

async function runBulkAudit() {
  const timestamp = new Date().toISOString();
  console.log(`\n[SEOPeek Cron] Starting bulk audit at ${timestamp}`);

  const urls = MONITORED_URLS.slice(0, BATCH_SIZE);
  const results = [];

  for (let i = 0; i < urls.length; i++) {
    const url = urls[i];
    try {
      const data = await auditUrl(url);
      results.push({ url, ...data, auditedAt: timestamp });

      const symbol = data.score >= MIN_SCORE ? '✓' : '✗';
      console.log(`[SEOPeek Cron] ${symbol} ${data.score}/100 (${data.grade}) ${url}`);

      if (data.score < MIN_SCORE) {
        // Hook in your alerting here: Slack, email, PagerDuty, etc.
        const failing = Object.entries(data.checks || {})
          .filter(([, v]) => !v.pass)
          .map(([k]) => k)
          .join(', ');
        console.warn(
          `[SEOPeek Cron] REGRESSION: ${url} scored ${data.score}. ` +
          `Failing: ${failing}`
        );
        // await sendSlackAlert({ url, score: data.score, failing });
      }
    } catch (err) {
      console.error(`[SEOPeek Cron] Error auditing ${url}: ${err.message}`);
      results.push({ url, error: err.message, auditedAt: timestamp });
    }

    if (i < urls.length - 1) await sleep(DELAY_MS);
  }

  console.log(
    `[SEOPeek Cron] Done. Audited ${results.length} URLs at ${new Date().toISOString()}`
  );

  return results;
}

/**
 * Start the scheduled SEO audit jobs.
 * Call this once from your app startup.
 */
function startSeoScheduler() {
  // Run every day at 06:00 UTC
  cron.schedule('0 6 * * *', runBulkAudit, {
    scheduled: true,
    timezone: 'UTC',
  });

  // Run every Monday at 08:00 UTC for a more thorough check
  cron.schedule('0 8 * * 1', runBulkAudit, {
    scheduled: true,
    timezone: 'UTC',
  });

  console.log('[SEOPeek Cron] Scheduler started. Audits run daily at 06:00 UTC.');
}

module.exports = { startSeoScheduler, runBulkAudit };

Start the scheduler when your Express app boots:

// app.js
const { startSeoScheduler } = require('./seo/scheduler');

const app = express();
// ... routes, middleware ...

app.listen(3000, () => {
  console.log('Server listening on port 3000');
  startSeoScheduler(); // Begin scheduled audits
});

Free tier note: The free plan allows 50 audits per day. With a BATCH_SIZE of 10 and two daily runs, you use 20 of your 50 daily audits and have headroom to spare. If you monitor more than 25 URLs, upgrade to the Starter plan ($9/month) which provides 1,000 audits per month.

6. Storing Audit Results in MongoDB and PostgreSQL

Running audits only has long-term value if you store the results. Historical data lets you answer questions like “when did the homepage score drop?”, “which pages have been below 70 for more than two weeks?”, and “what is our average SEO score trend over the last quarter?” Here are implementations for both MongoDB (with Mongoose) and PostgreSQL (with pg).

MongoDB with Mongoose

// seo/models/SeoAudit.js
const mongoose = require('mongoose');

const seoAuditSchema = new mongoose.Schema(
  {
    url:           { type: String, required: true, index: true },
    score:         { type: Number, required: true, index: true },
    grade:         { type: String, required: true },
    checks:        { type: mongoose.Schema.Types.Mixed, default: {} },
    failingCount:  { type: Number, default: 0 },
    auditedAt:     { type: Date,   default: Date.now, index: true },
  },
  { timestamps: false }
);

// Compound index for trend queries: all audits for a URL, newest first
seoAuditSchema.index({ url: 1, auditedAt: -1 });

seoAuditSchema.statics.latestForUrl = function(url) {
  return this.findOne({ url }).sort({ auditedAt: -1 });
};

seoAuditSchema.statics.scoreTrend = function(url, limit = 10) {
  return this.find({ url })
    .sort({ auditedAt: -1 })
    .limit(limit)
    .select('score grade auditedAt -_id');
};

seoAuditSchema.statics.regressedPages = function(threshold = 70) {
  // Pages that scored below threshold in the last 7 days
  const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
  return this.find({ score: { $lt: threshold }, auditedAt: { $gte: since } })
    .sort({ score: 1 });
};

module.exports = mongoose.model('SeoAudit', seoAuditSchema);

Save audit results from your scheduler:

// seo/storage/mongo.js
const SeoAudit = require('../models/SeoAudit');
const { auditUrl } = require('../client');

async function auditAndSave(url) {
  const data = await auditUrl(url);
  const failingCount = Object.values(data.checks || {})
    .filter(v => !v.pass).length;

  const doc = await SeoAudit.create({
    url:          data.url,
    score:        data.score,
    grade:        data.grade,
    checks:       data.checks,
    failingCount,
    auditedAt:    new Date(),
  });

  return doc;
}

async function detectRegression(url, dropThreshold = 5) {
  const recent = await SeoAudit.find({ url })
    .sort({ auditedAt: -1 })
    .limit(2)
    .select('score grade auditedAt');

  if (recent.length < 2) return null;
  const [current, previous] = recent;
  const drop = previous.score - current.score;
  if (drop >= dropThreshold) {
    return { url, current: current.score, previous: previous.score, drop };
  }
  return null;
}

module.exports = { auditAndSave, detectRegression };

PostgreSQL with pg

-- Run this migration once to create the audits table
CREATE TABLE seo_audits (
  id          BIGSERIAL PRIMARY KEY,
  url         TEXT        NOT NULL,
  score       SMALLINT    NOT NULL,
  grade       VARCHAR(5)  NOT NULL,
  checks      JSONB       NOT NULL DEFAULT '{}',
  failing_count SMALLINT  NOT NULL DEFAULT 0,
  audited_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_seo_audits_url            ON seo_audits (url);
CREATE INDEX idx_seo_audits_url_audited    ON seo_audits (url, audited_at DESC);
CREATE INDEX idx_seo_audits_score          ON seo_audits (score);
// seo/storage/postgres.js
const { Pool } = require('pg');
const { auditUrl } = require('../client');

const pool = new Pool({ connectionString: process.env.DATABASE_URL });

async function auditAndSave(url) {
  const data = await auditUrl(url);
  const failingCount = Object.values(data.checks || {})
    .filter(v => !v.pass).length;

  const { rows } = await pool.query(
    `INSERT INTO seo_audits (url, score, grade, checks, failing_count, audited_at)
     VALUES ($1, $2, $3, $4, $5, NOW())
     RETURNING *`,
    [url, data.score, data.grade, JSON.stringify(data.checks), failingCount]
  );

  return rows[0];
}

async function getScoreTrend(url, limit = 10) {
  const { rows } = await pool.query(
    `SELECT score, grade, audited_at
     FROM seo_audits
     WHERE url = $1
     ORDER BY audited_at DESC
     LIMIT $2`,
    [url, limit]
  );
  return rows;
}

async function getAverageScore() {
  // Average across the most recent audit per URL
  const { rows } = await pool.query(
    `SELECT ROUND(AVG(score), 1) AS avg_score
     FROM (
       SELECT DISTINCT ON (url) score
       FROM seo_audits
       ORDER BY url, audited_at DESC
     ) latest`
  );
  return parseFloat(rows[0].avg_score);
}

module.exports = { auditAndSave, getScoreTrend, getAverageScore };

PostgreSQL shines here for analytics. The DISTINCT ON pattern efficiently retrieves the latest audit per URL without a subquery index scan, and JSONB columns let you query specific failing checks with checks ->> 'og_tags' syntax. See also: building an SEO dashboard with audit data for a reporting layer on top of these tables.

7. Pricing and Rate Limits

SEOPeek offers three tiers. All use the same API endpoint and the same JSON response format—upgrading just raises your daily limit and removes the need for any throttling logic:

Plan Audits Price Best for
Free 50 / day $0 Local development, small sites, trying it out
Starter 1,000 / month $9/mo CI/CD pipelines, weekly scheduled audits, small teams
Pro 10,000 / month $29/mo Agencies, large sites, daily bulk audits across hundreds of pages

For the Express dev middleware, the free tier is more than sufficient—it only fires on pages you actually visit during development. For CI/CD deploy hooks auditing 5–10 critical pages per deploy, Starter covers most teams even at high deploy frequency. If you are running daily cron audits across 50+ pages, Pro is the practical choice.

The DELAY_MS = 1200 value in the bulk audit scripts above keeps you comfortably within the free tier's implied rate of one request per second. On Starter or Pro, you can reduce this to 500ms or lower. See bulk URL auditing at scale for strategies on handling large page inventories efficiently.

Frequently Asked Questions

Does the Express middleware slow down production requests?

No. The middleware checks process.env.NODE_ENV before doing anything and returns immediately when it is not 'development'. The one-line check adds negligible overhead—less than a microsecond. In production, run audits via the deploy hook or the node-cron scheduler, neither of which sits in the request path.

How many audits can I run for free?

The free tier provides 50 audits per day, resetting at midnight UTC. No API key is required. That is enough for active local development and small CI/CD pipelines. For higher volumes, the Starter plan at $9/month gives you 1,000 audits, and the Pro plan at $29/month gives you 10,000.

Can I use this with a TypeScript Express app?

Yes. Replace require() with import and add type annotations. Define an interface for the API response:

interface SeoAuditResult {
  url: string;
  score: number;
  grade: string;
  checks: Record<string, { pass: boolean; message: string }>;
}

async function auditUrl(url: string): Promise<SeoAuditResult> {
  const endpoint = new URL(SEOPEEK_API);
  endpoint.searchParams.set('url', url);
  const res = await fetch(endpoint.toString(), { signal: AbortSignal.timeout(30_000) });
  if (!res.ok) throw new Error(`SEOPeek API returned ${res.status}`);
  return res.json() as Promise<SeoAuditResult>;
}

Add @types/express for RequestHandler types on your middleware and route handlers. The rest compiles without changes.

How does node-cron compare to a system crontab for scheduling?

A system crontab is more resilient to process restarts, but it requires server access and adds operational overhead. node-cron lives inside your application process, which means it is simpler to deploy, easier to test locally (node -e "require('./seo/scheduler').runBulkAudit()"`), and shares your app's environment variables without any configuration. The main downside: if your process crashes and does not restart, the schedule stops. In practice, process managers like PM2 or container orchestrators like Kubernetes restart processes automatically, which negates this issue for most deployments.

Should I use MongoDB or PostgreSQL for storing audit results?

If your Express app already uses Mongoose, MongoDB is the path of least resistance—the flexible schema stores the checks object as a nested document with no migration work. PostgreSQL is the better choice for analytics: its DISTINCT ON pattern, indexed JSONB columns, and window functions make trend queries and regression detection significantly faster and more expressive at scale. Both implementations in this guide are production-ready.

Start Auditing Your Express App

50 free audits per day. No API key required to start.
Drop SEOPeek into your middleware in under 10 minutes.

Try SEOPeek Free →