Automation & Full-Stack Engineer

Automated Blog Engine: n8n + Gemini AI + Headless CMS

Built a fully automated blog publishing pipeline for a client with two distinct blog topics. Every few hours, the system scrapes trending topics, extracts context with Google Search, generates polished articles via Gemini API, and publishes them autonomously to two separate headless CMS instances.

Year
2025
Stack
n8nGemini APIGoogle Search APIDirectusSvelteKitWordPressDocker Compose

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
  end

The 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

MetricValue
Posts published (first 30 days)187 across both blogs
Avg. generation time per post~12 seconds end-to-end
Client intervention required0 (fully autonomous)
Cost (Gemini API, 30 days)~$1.40 USD
Workflow uptime99.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.