Automated Blog Engine: n8n + Gemini AI + Headless CMS
The Brief
The client already ran a WordPress blog, but had a problem: they wanted to scale content output across two entirely different topic verticals without hiring writers. One blog covered industry news in their niche; the other was a broader lifestyle/culture publication — different audiences, different tones, different content strategies.
The requirement was clear: hands-off, scheduled, AI-generated posts that still read like they were written by a human who knew the topic.
Infrastructure Overview
Two separate content pipelines, each running on their own n8n workflow, feeding into two separate publishing backends.
flowchart TD
subgraph n8n ["n8n (Self-Hosted)"]
direction TB
A["Workflow A (Niche Blog)<br>Cron: Every 4 hrs"]
B["Workflow B (Lifestyle Blog)<br>Cron: Every 6 hrs"]
C["Google Search API Node<br>(Trending queries)"]
D["Gemini API Node<br>(Structured prompt → article)"]
A --> C
B --> C
C --> D
E["WordPress REST API<br>(existing)"]
F["Directus REST API<br>(new blog)"]
D --> E
D --> F
endThe client's original WordPress site handles Workflow A. The new blog — built from scratch — handles Workflow B.
The New Blog Stack
The client's WordPress setup was too rigid for the second vertical. I needed a CMS the automation pipeline could write to via a clean REST API, with a frontend I could fully control. The stack I chose:
Directus — open-source headless CMS with a first-class REST/GraphQL API, role-based auth, and a built-in admin UI. Self-hosted.
SvelteKit — renders the blog UI, fetches content from Directus at request time (SSR), and handles routing. Deployed as a Node adapter inside Docker.
Docker Compose — ties the whole thing together on the client's existing VPS:
services:
directus:
image: directus/directus:11
environment:
SECRET: "${DIRECTUS_SECRET}"
DB_CLIENT: "sqlite3"
DB_FILENAME: "/directus/database/data.db"
ADMIN_EMAIL: "${ADMIN_EMAIL}"
ADMIN_PASSWORD: "${ADMIN_PASSWORD}"
volumes:
- directus_data:/directus/database
- directus_uploads:/directus/uploads
ports:
- "127.0.0.1:8055:8055"
blog:
build: ./blog-frontend
environment:
DIRECTUS_URL: "http://directus:8055"
PUBLIC_BASE_URL: "${PUBLIC_BASE_URL}"
ports:
- "127.0.0.1:3000:3000"
depends_on:
- directus
volumes:
directus_data:
directus_uploads:Nginx on the host handles SSL termination and proxies both api.blog.com (Directus) and blog.com (SvelteKit).
The n8n Pipeline — Step by Step
Each workflow follows the same 4-stage execution pattern, just with different topic seeds and target APIs.
Stage 1: Discover — Google Search API
The workflow opens with a Cron trigger, then immediately hits the Google Custom Search API with a set of seed queries tuned for that vertical. The node returns the top 10 results — titles, snippets, and source URLs.
// HTTP Request node config (Google Search)
{
"method": "GET",
"url": "https://www.googleapis.com/customsearch/v1",
"qs": {
"key": "{{ $env.GOOGLE_API_KEY }}",
"cx": "{{ $env.SEARCH_ENGINE_ID }}",
"q": "{{ $json.seed_query }} latest 2025",
"num": "10",
"dateRestrict": "d3"
}
}The dateRestrict: d3 parameter ensures only articles from the last 3 days surface — keeping content fresh.
Stage 2: Extract — Code Node
A lightweight JavaScript Code node processes the search results and distills them into a structured context object:
const items = $input.all();
const results = items[0].json.items ?? [];
const context = results.map((r) => ({
title: r.title,
snippet: r.snippet,
source: r.displayLink,
}));
// Extract unique entities (topics, people, brands mentioned)
const themes = [
...new Set(
context.flatMap(
(r) => r.snippet.match(/\b[A-Z][a-z]+(?:\s[A-Z][a-z]+)*/g) ?? [],
),
),
].slice(0, 15);
return [
{
json: {
context,
themes,
topic_seed: items[0].json._query,
},
},
];This gives Gemini a distilled list of named entities and topic snippets — not raw HTML — which dramatically improves generation quality.
Stage 3: Generate — Gemini API
The HTTP Request node calls gemini-3-flash (fast, cheap, good enough for blog content) with a structured system prompt that enforces output format (not exact prompt):
System: You are a professional blog writer. You write in an engaging,
informative style. Always output valid JSON with the following fields:
- title (string, SEO-optimized, max 70 chars)
- slug (string, kebab-case)
- excerpt (string, 150-160 chars, for meta description)
- content (string, full article in HTML, min 600 words)
- tags (array of strings, max 5)
User: Write an article based on the following trending topics and context.
Topic vertical: {{ $json.topic_seed }}
Recent themes: {{ $json.themes.join(', ') }}
Source context:
{{ $json.context.map(c => `- ${c.title}: ${c.snippet}`).join('\n') }}
The JSON output constraint is critical — it means the next node can parse the response directly without any text cleanup.
Stage 4: Publish
Workflow A (WordPress): Uses the WP REST API with an application password. A single POST to /wp-json/wp/v2/posts with status: publish and the generated HTML content.
Workflow B (Directus): Uses a static API token scoped to a content-bot role with write-only access to the posts collection:
// POST /items/posts
{
"title": "{{ $json.title }}",
"slug": "{{ $json.slug }}",
"excerpt": "{{ $json.excerpt }}",
"content": "{{ $json.content }}",
"tags": "{{ $json.tags }}",
"status": "published",
"date_published": "{{ new Date().toISOString() }}"
}Handling Failures Gracefully
n8n's built-in error workflow feature was used to catch and notify on failures. Any node failure triggers a separate Error Workflow that sends a Telegram message with the failed node name, execution ID, and error message — so the client can monitor without checking dashboards.
A duplicate detection step was also added before publishing: the Code node hashes the generated slug and checks a simple KV store (n8n's built-in static data) to skip re-publishing identical topics within a 48-hour window.
Results
| Metric | Value |
|---|---|
| Posts published (first 30 days) | 187 across both blogs |
| Avg. generation time per post | ~12 seconds end-to-end |
| Client intervention required | 0 (fully autonomous) |
| Cost (Gemini API, 30 days) | ~$1.40 USD |
| Workflow uptime | 99.6% (1 failure due to Google API quota) |
The client now publishes 6–8 posts per day across two blogs with zero manual effort. The Directus + SvelteKit blog went from concept to production in 3 days, with the automation pipeline validated and running on day 4.