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 │
└──────────────────────────────────────────────────────────────┘
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
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 }}"
}
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
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": "" }
]
}
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
}
}];
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]
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 }}"
}
}
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')
Yoast alternative:
meta: {
"_yoast_wpseo_title": "{{ $json.meta_title }}",
"_yoast_wpseo_metadesc": "{{ $json.meta_description }}",
"_yoast_wpseo_focuskw": "{{ $json.focus_keyword }}"
}
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()
}
// 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"
}]
}
]
}
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 }];
After logging:
- Write error to Google Sheets (
status = failed, log the error message) - Send Slack alert with keyword + error details
- 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]
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
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)
Solid walkthrough — the Sheets-as-buffer pattern is the right call; we run similar content automation and pushing raw AI drafts straight into
publishis 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.