Automating Blog Cross-posting to Dev.to and X with GitHub Actions
Publishing a blog post shouldn't require manually copying content to multiple platforms. I built a GitHub Actions workflow that deploys to Cloudflare, cross-posts to Dev.to, and posts to X—all with one click.
The Problem
Every time I publish a blog post, I need to:
- Deploy to Cloudflare Workers
- Copy the content to Dev.to (with proper canonical URL)
- Post to X about it
Doing this manually is tedious and error-prone. I wanted a single workflow that handles everything.
Architecture Overview
publish-blog-post.yml (GitHub Actions)
├── Deploy to Cloudflare Workers
├── Cross-post to Dev.to (with canonical_url)
├── Post to X (using existing OAuth 2.0 system)
└── Save IDs to frontmatter → auto-commit
The key insight: track what's already published in frontmatter. This prevents duplicate posts and enables article updates on Dev.to.
Implementation
Frontmatter Schema
Each blog post has optional fields for tracking published state:
---
title: "My Post"
description: "Post description"
pubDate: 2025-01-01
tags: ["tag1", "tag2"]
draft: false
devtoId: 123456 # Auto-set after Dev.to publish
tweetId: "1234567890" # Auto-set after posting to X
---
Dev.to Publishing Script
The script checks if devtoId exists to determine create vs. update:
// scripts/publish-devto.ts
import { readPost, updateFrontmatter, getCanonicalUrl } from './lib/frontmatter.js';
import { createArticle, updateArticle } from './lib/devto.js';
const post = readPost(slug);
const canonicalUrl = getCanonicalUrl(slug);
// Add footer linking to original
const footer = `
---
*Originally published at [shusukedev.com](${canonicalUrl})*`;
const article = {
title: post.frontmatter.title,
body_markdown: post.content + footer,
published: true,
tags: post.frontmatter.tags.slice(0, 4),
canonical_url: canonicalUrl,
};
if (post.frontmatter.devtoId) {
// Update existing
await updateArticle(post.frontmatter.devtoId, article);
} else {
// Create new
const result = await createArticle(article);
updateFrontmatter(slug, { devtoId: result.id });
}
X Integration
The script generates a scheduled post YAML file:
// scripts/tweet-post.ts
const postText = `New post: ${title}
${description}
${url}`;
const yamlContent = `platform: twitter
status: scheduled
scheduledFor: ${new Date().toISOString()}
content: |
${postText.split('\n').map(line => ' ' + line).join('\n')}
`;
writeFileSync(`posts/twitter/scheduled/${fileName}`, yamlContent);
The workflow then calls npm run post:publish to post.
GitHub Actions Workflow
name: Publish Blog Post
on:
workflow_dispatch:
inputs:
slug:
description: 'Article slug'
required: true
skip_twitter:
type: boolean
default: false
skip_devto:
type: boolean
default: false
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
# Deploy blog
- run: npm ci
working-directory: shusukedev-blog
- run: npm run build
working-directory: shusukedev-blog
- run: npx wrangler deploy
working-directory: shusukedev-blog
# Cross-post to Dev.to
- run: npm run post:devto -- ${{ inputs.slug }}
if: ${{ !inputs.skip_devto }}
working-directory: shusukedev-blog
# Post to X
- run: npm run post:tweet -- ${{ inputs.slug }}
if: ${{ !inputs.skip_twitter }}
working-directory: shusukedev-blog
- run: npm run post:publish ${{ steps.tweet.outputs.TWEET_FILE_PATH }}
if: ${{ !inputs.skip_twitter }}
# Commit ID updates
- run: |
git add .
git commit -m "Publish: ${{ inputs.slug }}" || true
git push
SEO Considerations
Cross-posting the same content can hurt SEO if not done correctly. Two safeguards:
- canonical_url - Tells search engines the original is on shusukedev.com
- Footer link - Visible to readers: "Originally published at shusukedev.com"
Dev.to respects canonical URLs and won't compete with your original in search results.
Update Behavior
| Service | First Run | Subsequent Runs |
|---|---|---|
| Cloudflare | Deploy | Re-deploy |
| Dev.to | Create (POST) | Update (PUT) |
| X | Post | Skip (tweetId exists) |
This means I can fix typos and the Dev.to version stays in sync, while X doesn't spam followers.
Tips
Preventing accidental links on X: X automatically converts domain-like strings (e.g., Dev.to) into clickable links, which can look messy. My script inserts a zero-width space (U+200B) after the dot to prevent this: Dev.\u200Bto looks identical but won't be linked.
Conclusion
With ~200 lines of TypeScript and a GitHub Actions workflow, I now publish everywhere with one click. The frontmatter tracking pattern could extend to other platforms like Hashnode or Medium.
The full implementation is in my blog's repository. Feel free to adapt it for your own workflow.
Originally published at shusukedev.com
Top comments (0)