March 29, 2026 · 16 min read

Build an SEO Monitoring Pipeline with n8n + SEOPeek

n8n is the self-hosted workflow automation tool that developers love for its flexibility, open-source codebase, and unlimited executions. Combined with the SEOPeek API, you can build a fully automated SEO monitoring pipeline that audits your entire site weekly, tracks scores over time in Google Sheets, detects regressions between runs, and sends color-coded Slack alerts when pages drop below your threshold—all without writing a backend from scratch or paying for expensive SEO SaaS tools.

In this guide
  1. What we are building
  2. Prerequisites
  3. Node 1: Schedule Trigger
  4. Node 2: Read URLs from Google Sheets
  5. Node 3: SplitInBatches
  6. Node 4: SEOPeek Audit (HTTP Request)
  7. Node 5: Filter low scores
  8. Node 6: Format results (Function node)
  9. Node 7: Slack alert
  10. Historical tracking with Google Sheets
  11. Detecting score regressions over time
  12. Advanced: Color-coded Slack alerts
  13. Import the complete n8n workflow JSON
  14. FAQ

1. What We Are Building

By the end of this guide you will have a self-hosted n8n SEO automation workflow that runs every Monday morning and does the following:

  1. Reads a list of URLs from a Google Sheet (your page inventory)
  2. Audits each URL against the SEOPeek API, receiving a score from 0–100, a letter grade, and a list of individual checks
  3. Appends each result to a historical tracking sheet with a timestamp
  4. Compares the current score to the previous week's score to detect regressions
  5. Filters pages that fall below your minimum threshold (default: 75)
  6. Sends a color-coded Slack message listing every page that needs attention, including which specific checks failed

This is a complete automated SEO monitoring n8n pipeline. It replaces expensive tools like Ahrefs Site Audit or Screaming Frog with a single API call per URL and a workflow you fully control.

2. Prerequisites

Before you start, make sure you have the following:

Tip: If you do not use Google Sheets, you can substitute a Notion database, Airtable base, or even a static JSON file read from disk. The workflow structure stays the same—only the data source node changes.

3. Node 1: Schedule Trigger

The Schedule Trigger fires the workflow on a cron schedule. For weekly SEO monitoring, set it to run every Monday at 9:00 AM.

In the n8n editor, add a Schedule Trigger node and configure it:

This gives you a weekly SEO health report at the start of the work week. If you prefer daily monitoring, change the expression to 0 9 * * *. For bi-weekly, use 0 9 * * 1/2.

The Schedule Trigger outputs a single item with the current timestamp, which downstream nodes can use for logging.

4. Node 2: Read URLs from Google Sheets

Add a Google Sheets node connected to the Schedule Trigger. Configure it to read from your URLs tab:

The node outputs one item per row, each containing a url field. If you have 30 pages to monitor, you get 30 items flowing into the next node.

Keep your URL list clean. Include only canonical URLs—no trailing slashes, no duplicate pages, no query parameters unless they represent distinct content. A typical list looks like this:

https://yoursite.com
https://yoursite.com/pricing
https://yoursite.com/blog
https://yoursite.com/blog/top-article
https://yoursite.com/features
https://yoursite.com/about
https://yoursite.com/contact

5. Node 3: SplitInBatches

The SplitInBatches node processes URLs one at a time. This is important for two reasons: it respects the SEOPeek API rate limits, and it prevents n8n from firing 50 HTTP requests simultaneously.

Connect the output of the Google Sheets node to the SplitInBatches input. The node will iterate through all URLs, passing one at a time to the next node, and looping back to itself until all items are processed.

Rate limiting: Add a Wait node between the HTTP Request and the SplitInBatches loop-back with a 1-second delay. This keeps you comfortably within the free tier's rate limits even with 50 URLs.

6. Node 4: SEOPeek Audit (HTTP Request)

This is the core of your n8n SEO audit workflow. Add an HTTP Request node and configure it:

// HTTP Request node configuration
{
  "method": "GET",
  "url": "https://seopeek.web.app/api/audit",
  "qs": {
    "url": "={{ $json.url }}"
  },
  "options": {
    "timeout": 30000
  }
}

The SEOPeek API responds with a JSON object containing:

{
  "url": "https://yoursite.com/pricing",
  "score": 82,
  "grade": "B",
  "checks": {
    "title": { "pass": true, "message": "Title tag found: 58 characters" },
    "meta_description": { "pass": true, "message": "Meta description found: 142 characters" },
    "h1": { "pass": true, "message": "Single H1 found" },
    "og_tags": { "pass": false, "message": "Missing og:image tag" },
    "twitter_card": { "pass": false, "message": "No twitter:card meta tag" },
    "structured_data": { "pass": true, "message": "JSON-LD found" },
    "canonical": { "pass": true, "message": "Canonical URL present" },
    "image_alt": { "pass": false, "message": "3 images missing alt attributes" }
  }
}

Every field is machine-readable. The score is an integer from 0 to 100. The grade is a letter from A+ to F. The checks object contains individual pass/fail results with human-readable messages.

7. Node 5: Filter Low Scores

Add an IF node to separate pages that need attention from those that are healthy:

Pages scoring 75 or above flow to the “true” output (healthy—no action needed). Pages below 75 flow to the “false” output and continue to the alert pipeline.

Adjust the threshold to match your standards. For most sites:

8. Node 6: Format Results (Function Node)

Add a Function node to transform the raw API responses into a readable report. This node collects all failing pages and builds a formatted summary:

// Function node: Format SEO Report
const items = $input.all();
const report = items.map(item => {
  const failedChecks = Object.entries(item.json.checks || {})
    .filter(([key, check]) => !check.pass)
    .map(([key, check]) => key)
    .join(', ');

  const arrow = item.json.score < 60 ? '!!!' : '!';

  return `${arrow} ${item.json.url} — Score: ${item.json.score}/100 (${item.json.grade})\n   Failed: ${failedChecks}`;
}).join('\n\n');

const count = items.length;
const summary = count === 0
  ? 'All pages passed the SEO threshold. No action needed.'
  : `${count} page(s) below threshold:\n\n${report}`;

return [{ json: { report: summary, count, timestamp: new Date().toISOString() } }];

The output is a single item with a report string, a count of failing pages, and a timestamp. This feeds directly into the Slack node.

9. Node 7: Slack Alert

Connect a Slack node to the Function node. Configure it to post to your team channel:

A typical alert looks like this in Slack:

SEO Weekly Report — 3 page(s) below threshold:

!!! https://example.com/old-landing — Score: 42/100 (F)
   Failed: h1, meta_description, og_tags

! https://example.com/about — Score: 65/100 (D)
   Failed: structured_data, image_alt

! https://example.com/blog/legacy-post — Score: 71/100 (C)
   Failed: image_alt, twitter_card

Start Monitoring for Free

SEOPeek's free tier gives you 50 audits/day—enough to monitor 350 pages weekly. No API key required.

View Pricing & Get Started →

10. Historical Tracking with Google Sheets

Raw alerts tell you what is broken right now. Historical tracking tells you when it broke and whether scores are trending up or down. Add a second Google Sheets node between the HTTP Request (Node 4) and the IF filter (Node 5) to log every audit result.

Configure the node:

// Mapping for Google Sheets Append
{
  "Date": "={{ new Date().toISOString().split('T')[0] }}",
  "URL": "={{ $json.url }}",
  "Score": "={{ $json.score }}",
  "Grade": "={{ $json.grade }}",
  "Failed Checks": "={{ Object.entries($json.checks || {}).filter(([k,v]) => !v.pass).map(([k]) => k).join(', ') }}"
}

After a few weeks, your History sheet builds a dataset that looks like this:

| Date       | URL               | Score | Grade | Failed Checks              |
|------------|-------------------|-------|-------|----------------------------|
| 2026-03-03 | /pricing          | 88    | B     |                            |
| 2026-03-03 | /blog             | 91    | A     |                            |
| 2026-03-10 | /pricing          | 88    | B     |                            |
| 2026-03-10 | /blog             | 85    | B     | image_alt                  |
| 2026-03-17 | /pricing          | 72    | C     | og_tags, twitter_card      |
| 2026-03-17 | /blog             | 91    | A     |                            |
| 2026-03-24 | /pricing          | 72    | C     | og_tags, twitter_card      |
| 2026-03-24 | /blog             | 93    | A     |                            |

The pricing page dropped 16 points between March 10 and March 17. That lines up with a deploy. Without historical tracking, you would only know the current score—not when the regression happened or how severe it was.

11. Detecting Score Regressions Over Time

Knowing the current score is useful. Knowing a page dropped 20 points since last week is actionable. Add a Function node after the Google Sheets append that compares the current score to the most recent previous score for each URL.

// Function node: Detect Regressions
// This node expects $json to have the current audit result
// and uses a Code node or sub-workflow to fetch the previous score

const currentUrl = $json.url;
const currentScore = $json.score;

// Pull previous score from the History sheet (fetched via a Google Sheets Read node)
const previousScores = $('Read Previous Scores').all();
const previousEntry = previousScores
  .filter(item => item.json.URL === currentUrl)
  .sort((a, b) => new Date(b.json.Date) - new Date(a.json.Date))[0];

const previousScore = previousEntry ? parseInt(previousEntry.json.Score) : null;
const delta = previousScore !== null ? currentScore - previousScore : null;

return [{
  json: {
    ...items[0].json,
    previousScore,
    delta,
    regression: delta !== null && delta < -5,
    regressionSeverity: delta !== null && delta <= -15 ? 'critical' : delta < -5 ? 'warning' : 'ok'
  }
}];

The key fields added:

You can now route regressions through a separate IF node: critical regressions go to an urgent Slack channel or PagerDuty, while warnings go to the regular #seo-alerts channel.

12. Advanced: Color-Coded Slack Alerts

Plain text alerts work, but Slack attachments with color-coded sidebars make regressions impossible to miss. Replace the simple Slack node with an HTTP Request node that posts directly to the Slack Webhook API with rich formatting:

// HTTP Request node: Slack Webhook with attachments
// Method: POST
// URL: https://hooks.slack.com/services/YOUR/WEBHOOK/URL
// Body (JSON):

{
  "text": "SEO Weekly Audit Complete",
  "attachments": [
    {
      "color": "#10B981",
      "title": "Passing Pages",
      "text": "{{ $json.passingCount }} pages scored above threshold",
      "footer": "SEOPeek Audit | {{ $json.timestamp }}"
    },
    {
      "color": "#F59E0B",
      "title": "Warning: Score Dropped 5-14 Points",
      "text": "{{ $json.warningPages }}",
      "footer": "Investigate within this sprint"
    },
    {
      "color": "#EF4444",
      "title": "Critical: Score Dropped 15+ Points",
      "text": "{{ $json.criticalPages }}",
      "footer": "Investigate immediately — likely caused by recent deploy"
    }
  ]
}

The color codes at a glance:

To build the warningPages and criticalPages strings, add a Function node before the Slack HTTP Request that groups results by severity:

// Function node: Group by severity
const items = $input.all();

const critical = items.filter(i => i.json.regressionSeverity === 'critical');
const warning = items.filter(i => i.json.regressionSeverity === 'warning');
const passing = items.filter(i => i.json.regressionSeverity === 'ok');

const formatPage = (item) => {
  const delta = item.json.delta !== null ? ` (${item.json.delta > 0 ? '+' : ''}${item.json.delta})` : '';
  const fails = Object.entries(item.json.checks || {})
    .filter(([k, v]) => !v.pass)
    .map(([k]) => k)
    .join(', ');
  return `*${item.json.url}* — ${item.json.score}/100${delta}\n  Failed: ${fails}`;
};

return [{
  json: {
    passingCount: passing.length,
    warningPages: warning.map(formatPage).join('\n') || 'None',
    criticalPages: critical.map(formatPage).join('\n') || 'None',
    timestamp: new Date().toISOString().split('T')[0],
    totalAudited: items.length
  }
}];

Pro tip: If you use Slack Block Kit instead of legacy attachments, you can add buttons like “View Full Report” that link directly to your Google Sheet, or “Re-audit Now” that triggers the n8n workflow via webhook.

13. Import the Complete n8n Workflow JSON

Copy the JSON below and import it into n8n via Settings → Import from JSON. You will need to configure your own Google Sheets and Slack credentials after importing.

{
  "name": "SEOPeek Weekly SEO Monitor",
  "nodes": [
    {
      "parameters": {
        "rule": { "interval": [{ "triggerAtHour": 9, "triggerAtDay": 1 }] }
      },
      "name": "Weekly Schedule",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1,
      "position": [240, 300]
    },
    {
      "parameters": {
        "operation": "read",
        "sheetId": "YOUR_SHEET_ID",
        "range": "URLs!A:A"
      },
      "name": "Read URLs",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4,
      "position": [460, 300],
      "credentials": { "googleSheetsOAuth2Api": { "id": "YOUR_CREDENTIAL_ID" } }
    },
    {
      "parameters": { "batchSize": 1 },
      "name": "SplitInBatches",
      "type": "n8n-nodes-base.splitInBatches",
      "typeVersion": 3,
      "position": [680, 300]
    },
    {
      "parameters": {
        "url": "https://seopeek.web.app/api/audit",
        "qs": { "url": "={{ $json.url }}" },
        "options": { "timeout": 30000 }
      },
      "name": "SEOPeek Audit",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4,
      "position": [900, 300]
    },
    {
      "parameters": { "amount": 1, "unit": "seconds" },
      "name": "Rate Limit Delay",
      "type": "n8n-nodes-base.wait",
      "typeVersion": 1,
      "position": [900, 480]
    },
    {
      "parameters": {
        "operation": "append",
        "sheetId": "YOUR_SHEET_ID",
        "range": "History!A:E",
        "columns": "Date,URL,Score,Grade,Failed Checks",
        "options": {}
      },
      "name": "Log to History",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4,
      "position": [1120, 300],
      "credentials": { "googleSheetsOAuth2Api": { "id": "YOUR_CREDENTIAL_ID" } }
    },
    {
      "parameters": {
        "conditions": {
          "number": [{ "value1": "={{ $json.score }}", "operation": "smaller", "value2": 75 }]
        }
      },
      "name": "Score Below 75?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 1,
      "position": [1340, 300]
    },
    {
      "parameters": {
        "functionCode": "const items = $input.all();\nconst report = items.map(item => {\n  const failedChecks = Object.entries(item.json.checks || {})\n    .filter(([k, v]) => !v.pass).map(([k]) => k).join(', ');\n  return `${item.json.url} — ${item.json.score}/100 (${item.json.grade})\\n  Failed: ${failedChecks}`;\n}).join('\\n\\n');\nreturn [{ json: { report, count: items.length } }];"
      },
      "name": "Format Report",
      "type": "n8n-nodes-base.function",
      "typeVersion": 1,
      "position": [1560, 240]
    },
    {
      "parameters": {
        "channel": "#seo-alerts",
        "text": "=SEO Weekly Report — {{ $json.count }} page(s) need attention:\n\n{{ $json.report }}"
      },
      "name": "Slack Alert",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2,
      "position": [1780, 240],
      "credentials": { "slackApi": { "id": "YOUR_CREDENTIAL_ID" } }
    }
  ],
  "connections": {
    "Weekly Schedule": { "main": [[{ "node": "Read URLs", "type": "main", "index": 0 }]] },
    "Read URLs": { "main": [[{ "node": "SplitInBatches", "type": "main", "index": 0 }]] },
    "SplitInBatches": { "main": [[{ "node": "SEOPeek Audit", "type": "main", "index": 0 }]] },
    "SEOPeek Audit": { "main": [[{ "node": "Rate Limit Delay", "type": "main", "index": 0 }]] },
    "Rate Limit Delay": { "main": [[{ "node": "Log to History", "type": "main", "index": 0 }]] },
    "Log to History": { "main": [[{ "node": "Score Below 75?", "type": "main", "index": 0 }]] },
    "Score Below 75?": {
      "main": [
        [{ "node": "Format Report", "type": "main", "index": 0 }],
        []
      ]
    },
    "Format Report": { "main": [[{ "node": "Slack Alert", "type": "main", "index": 0 }]] }
  }
}

After importing, update the following placeholders:

  1. YOUR_SHEET_ID — replace with your Google Sheets document ID (the long string in the sheet URL)
  2. YOUR_CREDENTIAL_ID — set up Google Sheets OAuth2 and Slack credentials in n8n Settings → Credentials
  3. Score threshold — adjust the 75 in the IF node to your preferred minimum
  4. Slack channel — change #seo-alerts to your team's channel

Self-hosted advantage: Because n8n runs on your own infrastructure, there are no execution limits, no per-task fees, and no data leaving your network (except the SEOPeek API call itself). This makes self-hosted SEO checks with n8n significantly cheaper than cloud-based alternatives at scale.

14. Going Further

Once the basic pipeline is running, here are ways to extend it:

Pricing

The SEOPeek API is designed to be affordable for automated workflows:

For a weekly workflow auditing 30 pages, you will never exceed the free tier. The Starter plan is worth it when you monitor multiple sites or want faster rate limits.

Build Your SEO Pipeline Today

Import the n8n workflow, connect your Google Sheet, and have automated SEO monitoring running in under 15 minutes.

Get Started Free →

FAQ

Can I use n8n Cloud or do I need a self-hosted instance for this workflow?

Both work. The workflow is fully compatible with n8n Cloud and self-hosted n8n. The only difference is that self-hosted instances give you unlimited executions, while n8n Cloud plans have execution limits depending on your tier.

How many URLs can I audit per workflow run with the free SEOPeek tier?

The free tier allows 50 audits per day. If your workflow runs weekly and audits fewer than 50 URLs per run, you will stay well within the free limit. For larger sites, the Starter plan at $9/month provides 1,000 audits per month.

Does the SEOPeek API require an API key?

No. The free tier requires no API key at all. Simply make a GET request to the audit endpoint with a url query parameter. API keys are only needed for paid plans with higher rate limits.

Can I trigger the n8n workflow manually instead of on a schedule?

Yes. You can trigger the workflow manually from the n8n editor using the Test Workflow button, or replace the Schedule Trigger with a Webhook Trigger so you can invoke it via HTTP request from CI/CD pipelines, Slack slash commands, or any other system.

How do I handle rate limiting when auditing many URLs?

The SplitInBatches node processes URLs one at a time by default. Add a Wait node after the HTTP Request node with a 1-second delay to stay well within rate limits. For paid plans, you can reduce this delay or increase the batch size.

The Peek Suite

SEOPeek is part of a family of developer-focused audit tools.