Automated blog engine: n8n + Gemini AI + headless CMS
Two topic verticals, no writers
The client already ran a WordPress blog and wanted to scale content output across two topic verticals without hiring writers. One blog covered industry news in their niche. The other was a broader lifestyle and culture publication, with a different audience, tone, and content strategy.
The requirement was hands-off, scheduled, AI-generated posts that still read like they were written by a human who knew the topic.
Two pipelines, two backends
Each pipeline runs its own n8n workflow and feeds its own publishing backend.
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 control. The stack I chose:
Directus: open-source headless CMS with a 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
Each workflow follows the same 4-stage pattern. Only the topic seeds and target APIs differ.
Stage 1: Discover
The workflow opens with a Cron trigger, then hits the Google Custom Search API with 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 limits results to articles from the last 3 days.
Stage 2: Extract
A JavaScript Code node processes the search results 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 instead of raw HTML, which improves generation quality.
Stage 3: Generate
The HTTP Request node calls gemini-3-flash, which is fast, cheap, and good enough for blog content. The structured system prompt below (not the exact prompt) enforces the output format:
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 what lets the next node parse the response directly, without 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
n8n's built-in error workflows catch 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.
I also added a duplicate detection step before publishing. The Code node hashes the generated slug and checks a simple KV store (n8n's built-in static data), so identical topics aren't re-published 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. The automation pipeline was validated and running on day 4.