DEV Community

Alex Kane
Alex Kane

Posted on

n8n for Media & Publishing: 5 Automations That Speed Up Content Operations (Free Workflow JSON)

The editorial cycle is brutal: monitor sources, write, edit, publish, syndicate, track performance — and repeat. Most media teams do every step manually.

Here are 5 n8n automations I use (or have built for publishing clients) that cut production time significantly. Full import-ready JSON below each one.


Why n8n for Media Operations?

Content operations teams have unique needs: breaking news must move in minutes, syndication goes to 10+ platforms, CMS workflows vary wildly between organizations, and analytics data comes from everywhere. Zapier and Make both get expensive fast when you need high-frequency monitoring, and — critically — story ideas, subscriber lists, and publishing schedules are commercially sensitive data you don't want routing through third-party cloud services.

n8n is free and self-hosted. Every story, every subscriber email, every analytics pull stays in your infrastructure.


Workflow 1: Breaking News Alert Pipeline

The problem: Monitoring 20+ RSS feeds manually for breaking stories means someone's always late to the story.

The workflow:

  • Trigger: Schedule every 5 minutes
  • HTTP Request: Pull each RSS feed URL from Google Sheets
  • Code node: Parse RSS XML, extract title + link + pubDate, filter items published within last 6 minutes (dedup window)
  • Code node: Scan headline for breaking keywords (breaking, urgent, alert, developing, exclusive)
  • IF node: Breaking keyword found?
  • True: Slack #breaking-news with headline, link, source, timestamp
  • True: Google Sheets log with dedup hash to prevent re-alerting same story
  • False: Continue to regular editorial Slack digest (separate daily workflow)
{
  "name": "Breaking News Alert Pipeline",
  "nodes": [
    {
      "parameters": {
        "rule": {"interval": [{"field": "minutes", "minutesInterval": 5}]}
      },
      "name": "Every 5 Min",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [240, 300]
    },
    {
      "parameters": {
        "documentId": {"__rl": true, "value": "YOUR_SHEET_ID", "mode": "id"},
        "sheetName": {"__rl": true, "value": "Feeds", "mode": "name"},
        "options": {}
      },
      "name": "Get RSS Feeds",
      "type": "n8n-nodes-base.googleSheets",
      "position": [460, 300]
    },
    {
      "parameters": {
        "url": "={{ $json.feed_url }}",
        "options": {}
      },
      "name": "Fetch RSS",
      "type": "n8n-nodes-base.httpRequest",
      "position": [680, 300]
    },
    {
      "parameters": {
        "jsCode": "const items = [];\nconst now = Date.now();\nconst sixMin = 6 * 60 * 1000;\nconst xml = $input.first().json.data;\nconst matches = xml.matchAll(/<item>[\\s\\S]*?<\\/item>/g);\nfor (const m of matches) {\n  const title = (m[0].match(/<title><!\\[CDATA\\[(.+?)\\]\\]><\\/title>/) || m[0].match(/<title>(.+?)<\\/title>/) || ['',''])[1];\n  const link = (m[0].match(/<link>([^<]+)/) || ['',''])[1];\n  const pub = (m[0].match(/<pubDate>(.+?)<\\/pubDate>/) || ['',''])[1];\n  const ts = pub ? new Date(pub).getTime() : now;\n  if (now - ts < sixMin) items.push({ title, link, pubDate: pub, source: $input.first().json.source_name });\n}\nreturn items.map(i => ({json: i}));"
      },
      "name": "Parse & Dedup",
      "type": "n8n-nodes-base.code",
      "position": [900, 300]
    },
    {
      "parameters": {
        "jsCode": "const keywords = ['breaking','urgent','alert','developing','exclusive','just in'];\nconst t = ($json.title || '').toLowerCase();\nreturn [{ json: { ...$json, is_breaking: keywords.some(k => t.includes(k)) } }];"
      },
      "name": "Check Breaking",
      "type": "n8n-nodes-base.code",
      "position": [1120, 300]
    },
    {
      "parameters": {
        "conditions": {"options": {"caseSensitive": false}, "conditions": [{"leftValue": "={{ $json.is_breaking }}", "rightValue": true, "operator": {"type": "boolean", "operation": "equals"}}]}
      },
      "name": "Is Breaking?",
      "type": "n8n-nodes-base.if",
      "position": [1340, 300]
    },
    {
      "parameters": {
        "select": "channel",
        "channelId": {"__rl": true, "value": "#breaking-news", "mode": "name"},
        "text": "=🚨 *BREAKING* — {{ $json.source }}\n*{{ $json.title }}*\n{{ $json.link }}\n_{{ $json.pubDate }}_",
        "otherOptions": {}
      },
      "name": "Slack Breaking Alert",
      "type": "n8n-nodes-base.slack",
      "position": [1560, 200]
    }
  ],
  "connections": {
    "Every 5 Min": {"main": [[{"node": "Get RSS Feeds", "type": "main", "index": 0}]]},
    "Get RSS Feeds": {"main": [[{"node": "Fetch RSS", "type": "main", "index": 0}]]},
    "Fetch RSS": {"main": [[{"node": "Parse & Dedup", "type": "main", "index": 0}]]},
    "Parse & Dedup": {"main": [[{"node": "Check Breaking", "type": "main", "index": 0}]]},
    "Check Breaking": {"main": [[{"node": "Is Breaking?", "type": "main", "index": 0}]]},
    "Is Breaking?": {"main": [[{"node": "Slack Breaking Alert", "type": "main", "index": 0}], []]}
  }
}
Enter fullscreen mode Exit fullscreen mode

Workflow 2: Editorial Calendar Content Notifier

The problem: Writers miss deadlines because no one reminded them. Editors are texting people the morning something's due.

The workflow:

  • Trigger: Schedule every day at 9 AM
  • Google Sheets: Pull editorial calendar (columns: headline, writer, due_date, status, section)
  • Code node: Filter rows where due_date is today or tomorrow and status is assigned (not filed or published)
  • Loop Over Items: For each due item:
    • Gmail: Email assigned writer with deadline reminder and story brief
    • Slack: Post to #editorial with writer tag, headline, and due time
  • Code node: Flag overdue items (due_date < today, status ≠ published) → Slack #editors escalation
{
  "name": "Editorial Calendar Notifier",
  "nodes": [
    {
      "parameters": {
        "rule": {"interval": [{"field": "cronExpression", "expression": "0 9 * * *"}]}
      },
      "name": "Daily 9AM",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [240, 300]
    },
    {
      "parameters": {
        "documentId": {"__rl": true, "value": "YOUR_SHEET_ID", "mode": "id"},
        "sheetName": {"__rl": true, "value": "Editorial Calendar", "mode": "name"},
        "options": {}
      },
      "name": "Get Calendar",
      "type": "n8n-nodes-base.googleSheets",
      "position": [460, 300]
    },
    {
      "parameters": {
        "jsCode": "const today = new Date(); today.setHours(0,0,0,0);\nconst tomorrow = new Date(today); tomorrow.setDate(tomorrow.getDate() + 1);\nconst overdue = [];\nconst upcoming = [];\nfor (const item of $input.all()) {\n  const { headline, writer_email, writer_name, due_date, status, section, slack_handle } = item.json;\n  if (!due_date || status === 'published' || status === 'filed') continue;\n  const due = new Date(due_date); due.setHours(0,0,0,0);\n  if (due < today) overdue.push({ ...item.json, days_overdue: Math.round((today - due) / 86400000) });\n  else if (due <= tomorrow) upcoming.push(item.json);\n}\nreturn [...upcoming.map(i => ({json: {...i, alert_type: 'upcoming'}})), ...overdue.map(i => ({json: {...i, alert_type: 'overdue'}}))];"
      },
      "name": "Filter Due Items",
      "type": "n8n-nodes-base.code",
      "position": [680, 300]
    },
    {
      "parameters": {
        "fromEmail": "editorial@yourpublication.com",
        "toEmail": "={{ $json.writer_email }}",
        "subject": "=📅 Deadline reminder: {{ $json.headline }}",
        "emailType": "html",
        "message": "=<p>Hi {{ $json.writer_name }},</p><p>This is a reminder that your piece <strong>{{ $json.headline }}</strong> ({{ $json.section }}) is due <strong>{{ $json.due_date }}</strong>.</p><p>Please file to the CMS by EOD. Reply to this email if you need an extension.</p>"
      },
      "name": "Email Writer",
      "type": "n8n-nodes-base.gmail",
      "position": [900, 200]
    }
  ],
  "connections": {
    "Daily 9AM": {"main": [[{"node": "Get Calendar", "type": "main", "index": 0}]]},
    "Get Calendar": {"main": [[{"node": "Filter Due Items", "type": "main", "index": 0}]]},
    "Filter Due Items": {"main": [[{"node": "Email Writer", "type": "main", "index": 0}]]}
  }
}
Enter fullscreen mode Exit fullscreen mode

Workflow 3: CMS Publish → Multi-Channel Syndication

The problem: Every time an article publishes, someone manually copies it to 6 platforms. It takes 20 minutes per story, and things get skipped under deadline pressure.

The workflow:

  • Trigger: Webhook — your CMS fires this on article publish
  • Code node: Extract title, excerpt, URL, author, section, tags, image URL from the CMS payload
  • HTTP Request (LinkedIn): POST to LinkedIn API — share article with generated copy
  • HTTP Request (Twitter/X): POST tweet with headline + link
  • HTTP Request (Facebook Page): POST to page feed
  • Google Sheets: Log syndication with timestamp, platform, post IDs
  • Slack: Confirm to #distribution channel with all post links
{
  "name": "CMS Publish to Multi-Channel",
  "nodes": [
    {
      "parameters": {"httpMethod": "POST", "path": "cms-publish", "options": {}},
      "name": "CMS Webhook",
      "type": "n8n-nodes-base.webhook",
      "position": [240, 300]
    },
    {
      "parameters": {
        "jsCode": "const p = $json.body || $json;\nreturn [{ json: {\n  title: p.title || p.headline,\n  url: p.url || p.permalink,\n  excerpt: (p.excerpt || p.description || '').substring(0, 200),\n  author: p.author?.name || p.byline,\n  section: p.section || p.category,\n  tags: Array.isArray(p.tags) ? p.tags.join(', ') : (p.tags || ''),\n  image_url: p.featured_image || p.image,\n  twitter_copy: `${(p.title || '').substring(0, 200)} ${p.url || p.permalink}`,\n  linkedin_copy: `${p.title}\\n\\n${(p.excerpt || '').substring(0, 200)}...\\n\\nRead more: ${p.url || p.permalink}`\n}}];"
      },
      "name": "Extract Content",
      "type": "n8n-nodes-base.code",
      "position": [460, 300]
    },
    {
      "parameters": {
        "url": "https://api.twitter.com/2/tweets",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "twitterOAuth2Api",
        "sendBody": true,
        "contentType": "json",
        "bodyParameters": {"parameters": [{"name": "text", "value": "={{ $json.twitter_copy }}"}]},
        "options": {}
      },
      "name": "Post to Twitter",
      "type": "n8n-nodes-base.httpRequest",
      "position": [680, 200]
    },
    {
      "parameters": {
        "select": "channel",
        "channelId": {"__rl": true, "value": "#distribution", "mode": "name"},
        "text": "=✅ Published & syndicated: *{{ $json.title }}*\n🔗 {{ $json.url }}",
        "otherOptions": {}
      },
      "name": "Slack Confirm",
      "type": "n8n-nodes-base.slack",
      "position": [680, 400]
    }
  ],
  "connections": {
    "CMS Webhook": {"main": [[{"node": "Extract Content", "type": "main", "index": 0}]]},
    "Extract Content": {"main": [[{"node": "Post to Twitter", "type": "main", "index": 0}], [{"node": "Slack Confirm", "type": "main", "index": 0}]]}
  }
}
Enter fullscreen mode Exit fullscreen mode

Workflow 4: Scheduled Social Media Posts from Content Queue

The problem: The social team manually composes posts from a Sheets queue, scheduling each one by hand in Buffer or Hootsuite — then paying $50–$200/month for those tools.

The workflow:

  • Trigger: Schedule every hour
  • Google Sheets: Read the social content queue (columns: platform, copy, link, image_url, scheduled_at, status)
  • Code node: Filter rows where scheduled_at is within the next 60 minutes and status = pending
  • Switch node: Route by platform
  • Twitter branch: POST tweet with copy + link
  • LinkedIn branch: POST share
  • Facebook branch: POST to page
  • Google Sheets (update): Mark rows as published with actual post time and post ID
{
  "name": "Scheduled Social from Content Queue",
  "nodes": [
    {
      "parameters": {
        "rule": {"interval": [{"field": "cronExpression", "expression": "0 * * * *"}]}
      },
      "name": "Hourly Check",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [240, 300]
    },
    {
      "parameters": {
        "documentId": {"__rl": true, "value": "YOUR_QUEUE_SHEET_ID", "mode": "id"},
        "sheetName": {"__rl": true, "value": "Social Queue", "mode": "name"},
        "options": {}
      },
      "name": "Read Queue",
      "type": "n8n-nodes-base.googleSheets",
      "position": [460, 300]
    },
    {
      "parameters": {
        "jsCode": "const now = Date.now();\nconst inOneHour = now + 3600000;\nreturn $input.all().filter(item => {\n  const { scheduled_at, status } = item.json;\n  if (status !== 'pending' || !scheduled_at) return false;\n  const t = new Date(scheduled_at).getTime();\n  return t >= now && t <= inOneHour;\n});"
      },
      "name": "Filter Due Posts",
      "type": "n8n-nodes-base.code",
      "position": [680, 300]
    },
    {
      "parameters": {
        "rules": {"values": [{"outputKey": "twitter", "conditions": {"conditions": [{"leftValue": "={{ $json.platform }}", "rightValue": "twitter", "operator": {"type": "string", "operation": "equals"}}]}}, {"outputKey": "linkedin", "conditions": {"conditions": [{"leftValue": "={{ $json.platform }}", "rightValue": "linkedin", "operator": {"type": "string", "operation": "equals"}}]}}]},
        "options": {}
      },
      "name": "Route by Platform",
      "type": "n8n-nodes-base.switch",
      "position": [900, 300]
    }
  ],
  "connections": {
    "Hourly Check": {"main": [[{"node": "Read Queue", "type": "main", "index": 0}]]},
    "Read Queue": {"main": [[{"node": "Filter Due Posts", "type": "main", "index": 0}]]},
    "Filter Due Posts": {"main": [[{"node": "Route by Platform", "type": "main", "index": 0}]]}
  }
}
Enter fullscreen mode Exit fullscreen mode

Workflow 5: Weekly Audience & Content Performance Report

The problem: The editor-in-chief wants to know which content performed, but building the weekly report takes 45 minutes of copying GA4 data into a spreadsheet.

The workflow:

  • Trigger: Schedule every Monday at 8 AM
  • Google Analytics API: Pull last 7 days — top 10 articles by sessions, total sessions, bounce rate, avg session duration
  • Google Sheets: Pull content published last week (with section tags)
  • Code node: Join GA4 data with content metadata, compute: sessions/article, email subscriber conversion rate, top-performing section, WoW session change
  • Code node: Build HTML email with metrics table, top 5 articles ranked by performance
  • Gmail: Send to editor-in-chief + section heads as BCC
{
  "name": "Weekly Content Performance Report",
  "nodes": [
    {
      "parameters": {
        "rule": {"interval": [{"field": "cronExpression", "expression": "0 8 * * 1"}]}
      },
      "name": "Monday 8AM",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [240, 300]
    },
    {
      "parameters": {
        "resource": "report",
        "propertyId": "YOUR_GA4_PROPERTY_ID",
        "dateRange": {"startDate": "7daysAgo", "endDate": "yesterday"},
        "dimensions": [{"name": "pagePath"}, {"name": "pageTitle"}],
        "metrics": [{"name": "sessions"}, {"name": "bounceRate"}, {"name": "averageSessionDuration"}],
        "limit": 10
      },
      "name": "GA4 Top Articles",
      "type": "n8n-nodes-base.googleAnalytics",
      "position": [460, 300]
    },
    {
      "parameters": {
        "jsCode": "const rows = $input.all();\nconst totalSessions = rows.reduce((s, r) => s + parseInt(r.json.sessions || 0), 0);\nconst top5 = rows.slice(0, 5);\nconst tableRows = top5.map(r =>\n  `<tr><td style='padding:8px;border-bottom:1px solid #eee'>${r.json.pageTitle}</td><td style='padding:8px;border-bottom:1px solid #eee;text-align:right'>${parseInt(r.json.sessions).toLocaleString()}</td><td style='padding:8px;border-bottom:1px solid #eee;text-align:right'>${(parseFloat(r.json.bounceRate)*100).toFixed(1)}%</td></tr>`\n).join('');\nconst html = `<html><body style='font-family:sans-serif;max-width:600px;margin:0 auto'><h2 style='color:#1a1a1a'>Weekly Content Report — ${new Date().toDateString()}</h2><p><strong>Total Sessions (7d):</strong> ${totalSessions.toLocaleString()}</p><h3>Top 5 Articles</h3><table style='width:100%;border-collapse:collapse'><thead><tr style='background:#f5f5f5'><th style='padding:8px;text-align:left'>Article</th><th style='padding:8px;text-align:right'>Sessions</th><th style='padding:8px;text-align:right'>Bounce Rate</th></tr></thead><tbody>${tableRows}</tbody></table><p style='color:#888;font-size:12px'>Generated by n8n — <a href='https://stripeai.gumroad.com'>FlowKit n8n Templates</a></p></body></html>`;\nreturn [{ json: { html, totalSessions } }];"
      },
      "name": "Build Report",
      "type": "n8n-nodes-base.code",
      "position": [680, 300]
    },
    {
      "parameters": {
        "fromEmail": "analytics@yourpublication.com",
        "toEmail": "editor@yourpublication.com",
        "subject": "=Weekly Content Report — {{ $now.format('MMM D, YYYY') }}",
        "emailType": "html",
        "message": "={{ $json.html }}"
      },
      "name": "Email Report",
      "type": "n8n-nodes-base.gmail",
      "position": [900, 300]
    }
  ],
  "connections": {
    "Monday 8AM": {"main": [[{"node": "GA4 Top Articles", "type": "main", "index": 0}]]},
    "GA4 Top Articles": {"main": [[{"node": "Build Report", "type": "main", "index": 0}]]},
    "Build Report": {"main": [[{"node": "Email Report", "type": "main", "index": 0}]]}
  }
}
Enter fullscreen mode Exit fullscreen mode

The Self-Hosting Advantage for Media Teams

News is commercially valuable before it's public. Story ideas, source contacts, publishing schedules, subscriber lists — this is your competitive moat. Routing it through Zapier's or Make's cloud servers means it transits third-party infrastructure by definition.

Self-hosted n8n means your editorial data stays inside your network. Every workflow is a versioned JSON file you can git-commit, audit, and roll back. No usage caps during breaking news. Free at any volume.

Feature n8n (self-hosted) Zapier Make.com
Data hosting Your servers Zapier's cloud Make's cloud
Cost at 50k ops/mo ~$0 $299/mo $59/mo
RSS polling (5-min) Free Pro plan required Operations-heavy
Breaking news speed Your infrastructure Cloud latency Cloud latency
Workflow versioning Git-native JSON None None

What to Build First

If you're a media/publishing team just getting started with n8n:

  1. Start with the Breaking News Pipeline — immediate value, no external API keys needed beyond RSS + Slack
  2. Add the Editorial Calendar Notifier — stops missed deadlines within the first week
  3. Wire up the Weekly Performance Report — replaces the Monday morning manual GA4 session

All five workflows above are included in the FlowKit n8n Template Bundle — ready-to-import JSON with documentation, covering 15 automation use cases across content operations, email, CRM, and more.


Have questions about wiring these up for your CMS? Drop a comment below — happy to help with specific integrations (WordPress, Ghost, Contentful, etc.).

Top comments (0)