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-newswith 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}], []]}
}
}
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_dateis today or tomorrow andstatusisassigned(notfiledorpublished) -
Loop Over Items: For each due item:
- Gmail: Email assigned writer with deadline reminder and story brief
-
Slack: Post to
#editorialwith writer tag, headline, and due time
-
Code node: Flag overdue items (due_date < today, status ≠ published) → Slack
#editorsescalation
{
"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}]]}
}
}
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
#distributionchannel 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}]]}
}
}
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_atis within the next 60 minutes andstatus = 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
publishedwith 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}]]}
}
}
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}]]}
}
}
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:
- Start with the Breaking News Pipeline — immediate value, no external API keys needed beyond RSS + Slack
- Add the Editorial Calendar Notifier — stops missed deadlines within the first week
- 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)