DEV Community

Mateo Ruiz
Mateo Ruiz

Posted on

Building a Keyword-to-WordPress SEO Pipeline with n8n, Google Sheets & AI

Publishing 10 SEO articles a month is manageable. Publishing 40+ is a production infrastructure problem and the fix isn't hiring more writers. It's automating the mechanical steps between a keyword and a live WordPress draft.

This post walks through a production-ready n8n workflow that takes a keyword from Google Sheets, generates structured SEO content via AI, creates a fully populated WordPress draft through the REST API, and notifies a reviewer before anything goes live.

Free workflow JSON at the end import and run on your first keyword in under an hour.

Why Manual SEO Publishing Breaks at Scale

A single SEO post involves roughly 6–7 handoffs across multiple tools:

  • Keyword assignment → brief → draft → edit → SEO review → image → WordPress upload → meta fields → publish

That's 3–4 hours of combined work per post, nothing carries over between articles, and under volume pressure the "optional" steps get skipped — meta descriptions left blank, images without alt text, no internal links, drafts stuck for weeks with no review owner.

An n8n pipeline eliminates every mechanical step in that list while keeping a human in the loop before anything goes live.

Architecture

┌──────────────────────────────────────────────────────────────┐
│                    n8n SEO Pipeline                          │
│                                                              │
│  [Schedule Trigger] OR [Webhook Trigger]                     │
│           ↓                                                  │
│  [Google Sheets] → filter status=pending, read next row      │
│           ↓                                                  │
│  [Set Node] → update status to in_progress                   │
│           ↓                                                  │
│  [Switch Node] → route by content_type                       │
│     ↓              ↓               ↓                         │
│  blog_post    faq_page      location_page                    │
│     ↓              ↓               ↓                         │
│  [AI Node: structured content generation]                    │
│           ↓                                                  │
│  [Function Node] → parse JSON response                       │
│           ↓                          ↓                       │
│  [WP Media Upload]          [WP Create Draft Post]           │
│                    ↓                                         │
│         [Google Sheets: update status=published]             │
│                    ↓                                         │
│         [Slack Notification: reviewer alert]                 │
│                    ↓                                         │
│              [Error Branch]                                  │
│    Log → Alert → Set status=failed                           │
└──────────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Stack

Tool Role
n8n (self-hosted or Cloud) Orchestration
Google Sheets Keyword input + status tracking
OpenAI / Claude / Gemini Content generation
WordPress REST API Draft creation + meta population
Rank Math / Yoast (REST ext.) SEO meta fields
Slack Review notification

Node-by-Node Breakdown

1. Trigger

Schedule Trigger for consistent publishing cadence:

Cron: 0 8 * * 1,3,5   # Mon/Wed/Fri at 8am
Enter fullscreen mode Exit fullscreen mode

Webhook Trigger for on-demand generation from Slack commands, form submissions, or external systems. Both feed the same downstream nodes.

2. Google Sheets — Keyword Database

Column Structure

Column Type Notes
keyword string Target keyword
slug string URL slug (pre-defined or auto)
content_type enum blog_post, faq_page, location_page
word_count number Target word count
category_id number WordPress category ID
status enum pending, in_progress, published, failed
draft_url string Populated after draft creation
generated_at datetime Timestamp of last run

Read + Immediate Lock

Filter for status = pending, return first row only. Immediately update that row to status = in_progress before any API calls. This prevents duplicate processing if the workflow re-triggers before completion.

// Set node — build processing context
{
  keyword: "{{ $json.keyword }}",
  slug: "{{ $json.slug }}",
  content_type: "{{ $json.content_type }}",
  word_count: "{{ $json.word_count || 1200 }}",
  category_id: "{{ $json.category_id }}"
}
Enter fullscreen mode Exit fullscreen mode

3. Switch Node — Route by Content Type

Route to different prompt templates based on content_type:

content_type == "blog_post"     → Blog Post Prompt Node
content_type == "faq_page"      → FAQ Page Prompt Node  
content_type == "location_page" → Location Page Prompt Node
Enter fullscreen mode Exit fullscreen mode

Each prompt node is a Set node that builds the system prompt for that content type before the AI call.

4. AI Content Generation

Prompt Structure (Blog Post)

You are an SEO content writer for [Company/Site Name].

Audience: [description of target reader]
Tone: [conversational / authoritative / technical]
Style rules: [direct sentences, no "In conclusion", no filler phrases]

Write a complete SEO article for this keyword:
Keyword: {{ $json.keyword }}
Target word count: {{ $json.word_count }}

Return ONLY valid JSON  no preamble, no markdown fences:
{
  "meta_title": "",        // 60 chars, include keyword naturally
  "meta_description": "", // 155 chars, include keyword, action-oriented
  "h1": "",               // Main headline, different from meta_title
  "h2_outline": [],       // Array of 3-4 H2 strings
  "body_html": "",        // Full article in HTML: <h2>, <p>, <ul>, <ol>
  "focus_keyword": "",    // Exact match keyword
  "faq": [
    { "question": "", "answer": "" },
    { "question": "", "answer": "" },
    { "question": "", "answer": "" }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Prompt quality directly determines output quality — this is not a place to cut corners. The more specific the audience, tone, and format instructions, the less editing the output needs.

Parse the AI Response

// Function Node — parse AI JSON safely
const raw = $input.first().json.content[0].text;

// Strip accidental markdown fences (model occasionally adds them)
const clean = raw
  .replace(/^```
{% endraw %}
json\s*/i, '')
  .replace(/\s*
{% raw %}
```$/, '')
  .trim();

let parsed;
try {
  parsed = JSON.parse(clean);
} catch (e) {
  // Log raw output for debugging, then throw to trigger error branch
  throw new Error(`JSON parse failed. Raw output: ${raw.substring(0, 300)}`);
}

return [{
  json: {
    meta_title: parsed.meta_title,
    meta_description: parsed.meta_description,
    h1: parsed.h1,
    body_html: parsed.body_html,
    focus_keyword: parsed.focus_keyword,
    faq_json: JSON.stringify(parsed.faq),
    h2_outline: parsed.h2_outline
  }
}];
Enter fullscreen mode Exit fullscreen mode

5. WordPress — Media Upload

Upload the featured image to the WordPress media library before creating the post:

POST /wp-json/wp/v2/media
Headers:
  Authorization: Basic [base64 username:app_password]
  Content-Disposition: attachment; filename="seo-post-image.jpg"
  Content-Type: image/jpeg
Body: [raw image binary or URL-fetched binary]
Enter fullscreen mode Exit fullscreen mode

Store the returned id field — this becomes the featured_media value in the post creation call.

6. WordPress — Create Draft Post

// POST /wp-json/wp/v2/posts
{
  title: "{{ $json.h1 }}",
  content: "{{ $json.body_html }}",
  status: "draft",              // ALWAYS draft — never publish directly
  slug: "{{ $json.slug }}",
  categories: [{{ $json.category_id }}],
  featured_media: {{ $('WP Media Upload').first().json.id }},
  meta: {
    // Rank Math
    "rank_math_focus_keyword": "{{ $json.focus_keyword }}",
    "rank_math_description": "{{ $json.meta_description }}",
    "rank_math_title": "{{ $json.meta_title }}"
  }
}
Enter fullscreen mode Exit fullscreen mode

Authentication: Use WordPress Application Passwords (available since WP 5.6). Create a dedicated app password with Editor role — never use admin credentials.

// Authorization header value
'Basic ' + btoa('your_wp_username:xxxx xxxx xxxx xxxx xxxx xxxx')
Enter fullscreen mode Exit fullscreen mode

Yoast alternative:

meta: {
  "_yoast_wpseo_title": "{{ $json.meta_title }}",
  "_yoast_wpseo_metadesc": "{{ $json.meta_description }}",
  "_yoast_wpseo_focuskw": "{{ $json.focus_keyword }}"
}
Enter fullscreen mode Exit fullscreen mode

Note: Yoast custom meta via REST requires the wpseo/v1 endpoint or an additional plugin. Test this before building the full pipeline.


7. Post-Publish: Status Update + Slack Alert

// Google Sheets update
{
  status: "published",
  draft_url: "https://yoursite.com/wp-admin/post.php?post={{ $json.id }}&action=edit",
  generated_at: new Date().toISOString()
}
Enter fullscreen mode Exit fullscreen mode
// Slack Block Kit notification
{
  blocks: [
    {
      type: "section",
      text: {
        type: "mrkdwn",
        text: `📝 *New SEO draft ready*\n*Keyword:* ${keyword}\n*Title:* ${h1}\n*Est. words:* ~${wordCount}`
      }
    },
    {
      type: "actions",
      elements: [{
        type: "button",
        text: { type: "plain_text", text: "Review in WordPress ↗" },
        url: draftUrl,
        style: "primary"
      }]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

8. Error Handling

Configure n8n's error output on every API node. Build a dedicated error branch:

// Error branch function node
const errorLog = {
  timestamp: new Date().toISOString(),
  pipeline: "SEO Content Workflow",
  keyword: $('Google Sheets').first().json.keyword,
  failed_node: $input.first().json.nodeName || "unknown",
  error: $input.first().json.error?.message || "Unknown",
  status_code: $input.first().json.error?.statusCode
};
return [{ json: errorLog }];
Enter fullscreen mode Exit fullscreen mode

After logging:

  1. Write error to Google Sheets (status = failed, log the error message)
  2. Send Slack alert with keyword + error details
  3. Do not retry automatically for 401s (expired credentials) or 422s (malformed data) — these need human intervention

Common Failures and Fixes

Failure Cause Fix
AI JSON parse error Model added markdown fences Strip with regex before parsing
WordPress 401 App password expired/revoked Alert team, manual credential refresh
WordPress 422 Malformed meta field or invalid category ID Log full request body, check field mapping
Google Sheets quota Trigger polling too frequently Back off trigger cadence, batch reads
AI timeout Long word count target Set 90s timeout, retry once
Duplicate processing Status not updated before failure Mark in_progress immediately after read

Customisation Options

Add SERP Research Before AI Generation

[Google Sheets: Read keyword]
         ↓
[DataForSEO: Get top 10 results for keyword]
         ↓
[Function: Extract titles, H2s, meta descriptions from SERP]
         ↓
[AI Node: Generate content informed by SERP context]
Enter fullscreen mode Exit fullscreen mode

Adds latency (~10-15s) but significantly improves topical relevance. Worth it for competitive keywords.

Approval Gate for Regulated Industries

[Draft created in WordPress]
         ↓
[Send approval email: Approve / Reject links]
         ↓
[n8n Wait Node] ← workflow pauses here
         ↓
[Webhook: receives decision]
         ↓
[IF approved] → update Sheets → notify team
[IF rejected] → flag for manual edit → notify
Enter fullscreen mode Exit fullscreen mode

Multi-Client / Multi-Site

Run separate workflow instances per client rather than conditional branching inside one workflow. Easier to debug, permission, and hand off. Each instance has its own Sheets source, WordPress credentials, prompt template, and Slack channel.

Rate Limits at Scale

Service Limit Mitigation
OpenAI GPT-4o 10K TPM (Tier 1) Add 30-60s delay between iterations
Claude API 40 RPM (base) Stagger scheduled runs
Google Sheets 300 reads/min Batch keyword reads
WordPress REST Server-dependent Monitor server response times

For high-volume batches (50+ posts/week), run multiple staggered workflow instances rather than one large batch.

What This Pipeline Can't Do

Be honest with yourself about these limits before you go live:

Fact-check. AI models generate plausible content. For technical, medical, legal, or financial topics, plausible ≠ accurate. Human review is not optional.

Research the SERP by default. Without the DataForSEO step, the model has no visibility into what's ranking. It generates against the keyword text alone.

Populate internal links. The model has no knowledge of your existing content. Internal linking is a manual review step.

Replace editorial judgment. The workflow handles production throughput, not content strategy, quality assessment, or brand voice decisions.


Get the Free Workflow JSON

IT Path Solutions published the complete n8n workflow Google Sheets trigger, Switch routing by content type, AI generation node with structured prompt, WordPress REST API publish, Slack notification, status update logic, and error handling all pre-connected.

Import into any n8n instance, add credentials, run against one keyword first.

👉 Download the free n8n SEO workflow JSON + setup guide


Summary

The pipeline earns its value by eliminating the mechanical steps keyword pull, draft generation, WordPress upload, meta field population, reviewer notification while keeping a human in the loop before anything goes live.

Run it against one keyword. Review the draft. Adjust the prompt. Then decide if it's ready for the full list.


Questions about a specific node or API integration? Drop them in the comments.

Top comments (1)

Collapse
 
raknaos profile image
Baptiste Le Bouquin

Solid walkthrough — the Sheets-as-buffer pattern is the right call; we run similar content automation and pushing raw AI drafts straight into publish is where pipelines like this tend to burn people (either quality or indexing-wise).

Two failure points worth flagging from experience: the featured-image step is where n8n pipelines usually break first — binary handling for the media upload + post_meta (_thumbnail_id) wiring is fiddly, and image optimization plugins on the WP side can silently reject what n8n uploads. And if the WP account has 2FA, Application Passwords are the durable path for the n8n HTTP node — password auth gets revoked on security plugin sweeps.

How do you handle internal linking between generated posts? That's the part we never found a good automated answer for — clusters that interlink well need awareness of the whole set, which doesn't fit the one-row-per-keyword model cleanly.