Introduction
As developers who maintain technical blogs, we often face a common dilemma: should we publish exclusively on our own site, or should we cross-post to platforms like Dev.to, Medium, or Hashnode to reach a wider audience?
The answer is usually "both," but that creates a new problem: manual cross-posting is tedious, error-prone, and time-consuming. You write a post in Hugo, publish it to your site, then manually copy-paste the content to Dev.to, adjust the formatting, add tags, set the canonical URL, and hope you didn't miss anything.
I experienced this friction firsthand with my Hugo-powered blog at blog.walsen.website. After publishing several posts and manually cross-posting them to Dev.to, I realized this workflow was unsustainable. There had to be a better way.
That's when I decided to build hugo2devto: a GitHub Action that automatically publishes Hugo blog posts to Dev.to with full frontmatter support, canonical URLs, and zero manual intervention.
The Problem: Cross-Posting is Painful
Let me paint a picture of the traditional workflow:
- Write your post in Hugo with YAML frontmatter
- Build and deploy your Hugo site
- Open Dev.to in your browser
- Copy-paste your markdown content
- Manually configure:
- Title
- Tags (max 4)
- Cover image
- Canonical URL
- Published/draft status
- Series information
- Preview and publish
- Repeat for every post update
This process has several problems:
- Time-consuming: 10-15 minutes per post
- Error-prone: Easy to forget canonical URLs or tags
- Not scalable: Discourages cross-posting
- No version control: Changes aren't tracked
- Manual synchronization: Updates require repeating the entire process
The Solution: Automation Through GitHub Actions
The ideal solution would:
- Detect when a Hugo post is created or updated
- Automatically extract frontmatter metadata
- Publish or update the post on Dev.to
- Set the correct canonical URL
- Handle tags, cover images, and series
- Work seamlessly with existing Hugo workflows
This is exactly what hugo2devto does.
Building the Action: Technical Deep Dive
Architecture Overview
The action is built with TypeScript and runs on Node.js 20. Here's the high-level architecture:
Key Features
1. Full Hugo Frontmatter Support
The action understands Hugo's frontmatter format natively:
---
title: "My Awesome Post"
description: "A deep dive into something cool"
publishdate: 2026-01-25T22:23:37-04:00
draft: false
tags: ["hugo", "devto", "automation"]
series: "Hugo Automation"
eyecatch: "https://example.com/cover.png"
---
It automatically maps these fields to Dev.to's API format:
-
title→title -
description→description -
tags→tags(limited to 4) -
series→series -
eyecatch/cover_image→main_image -
draft→published(inverted)
2. Automatic Canonical URL Generation
One of the most important SEO considerations when cross-posting is setting the canonical URL to point back to your original post. The action automatically generates this:
const canonicalUrl = `${baseUrl}/${language}/posts/${slug}/`
For example, a post at content/en/posts/my-post.md becomes:
https://blog.walsen.website/en/posts/my-post/
3. Multi-Language Support
The action detects the language from the file path:
content/en/posts/my-post.md → English
content/es/posts/mi-post.md → Spanish
This is crucial for Hugo sites with i18n support.
4. Mermaid Diagram Support (v1.1.0)
Hugo uses shortcodes for mermaid diagrams, but Dev.to doesn't support mermaid natively. The action automatically converts Hugo mermaid shortcodes to rendered PNG images using the mermaid.ink service:
<!-- Hugo format (in your source) -->
{{</* mermaid */>}}
flowchart TD
A --> B
{{</* /mermaid */>}}
<!-- Converted to (on Dev.to) -->

This means your diagrams render beautifully on both platforms without any manual intervention.
5. Idempotent Updates
The action checks if an article already exists on Dev.to (by canonical URL) and updates it instead of creating a duplicate. This means you can run the action multiple times safely.
Implementation Highlights
Here's a simplified version of the core logic:
// Read and parse the markdown file
const fileContent = fs.readFileSync(filePath, 'utf-8');
const { data: frontmatter, content } = matter(fileContent);
// Extract metadata
const title = frontmatter.title;
const description = frontmatter.description || '';
const tags = (frontmatter.tags || []).slice(0, 4); // Dev.to limit
const published = !frontmatter.draft;
// Generate canonical URL
const slug = path.basename(filePath, '.md')
.toLowerCase()
.replace(/\s+/g, '-');
const language = filePath.includes('/en/') ? 'en' : 'es';
const canonicalUrl = `${baseUrl}/${language}/posts/${slug}/`;
// Prepare Dev.to article
const article = {
title,
body_markdown: content,
published,
tags,
canonical_url: canonicalUrl,
main_image: frontmatter.eyecatch || frontmatter.cover_image,
series: frontmatter.series,
description
};
// Check if article exists
const existingArticle = await findArticleByCanonicalUrl(canonicalUrl);
if (existingArticle) {
// Update existing article
await updateArticle(existingArticle.id, article);
} else {
// Create new article
await createArticle(article);
}
Using the Action: Practical Examples
Basic Setup
First, get your Dev.to API key from https://dev.to/settings/extensions and add it to your repository secrets as DEVTO_API_KEY.
Then create .github/workflows/publish-devto.yml:
name: Publish to Dev.to
on:
push:
branches: [main]
paths:
- 'content/*/posts/*.md'
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Publish to Dev.to
uses: Walsen/hugo2devto@v1
with:
api-key: ${{ secrets.DEVTO_API_KEY }}
file-path: 'content/en/posts/my-post.md'
base-url: 'https://blog.walsen.website'
Advanced: Automatic Detection of Changed Posts
For my blog, I wanted the action to automatically detect which posts changed and publish only those:
name: Publish to Dev.to
on:
push:
branches: [main]
paths:
- 'content/*/posts/*.md'
jobs:
detect-changes:
runs-on: ubuntu-latest
outputs:
posts: ${{ steps.changed-files.outputs.posts }}
has-changes: ${{ steps.changed-files.outputs.has-changes }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Get changed files
id: changed-files
run: |
CHANGED_FILES=$(git diff --name-only HEAD^ HEAD | grep -E 'content/en/posts/.*\.md' || echo "")
if [ -n "$CHANGED_FILES" ]; then
POSTS_JSON=$(echo "$CHANGED_FILES" | jq -R -s -c 'split("\n") | map(select(length > 0))')
echo "posts=$POSTS_JSON" >> $GITHUB_OUTPUT
echo "has-changes=true" >> $GITHUB_OUTPUT
else
echo "posts=[]" >> $GITHUB_OUTPUT
echo "has-changes=false" >> $GITHUB_OUTPUT
fi
publish-changed:
if: needs.detect-changes.outputs.has-changes == 'true'
needs: detect-changes
runs-on: ubuntu-latest
strategy:
matrix:
post: ${{ fromJson(needs.detect-changes.outputs.posts) }}
fail-fast: false
steps:
- uses: actions/checkout@v4
- name: Publish to Dev.to
uses: Walsen/hugo2devto@v1
with:
api-key: ${{ secrets.DEVTO_API_KEY }}
file-path: ${{ matrix.post }}
base-url: 'https://blog.walsen.website'
This workflow:
- Detects which markdown files changed in the last commit
- Converts them to a JSON array
- Uses a matrix strategy to publish multiple posts in parallel
- Sets
fail-fast: falseso one failure doesn't stop others
Manual Trigger
You can also trigger publishing manually:
on:
workflow_dispatch:
inputs:
post_path:
description: 'Path to the post'
required: true
type: string
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Publish to Dev.to
uses: Walsen/hugo2devto@v1
with:
api-key: ${{ secrets.DEVTO_API_KEY }}
file-path: ${{ github.event.inputs.post_path }}
base-url: 'https://blog.walsen.website'
Real-World Results
Since implementing this action on my blog, the results have been transformative:
Before:
- ⏱️ 15 minutes per post to cross-post manually
- 🐛 Frequent mistakes (forgotten canonical URLs, wrong tags)
- 😓 Discouraged from updating posts on Dev.to
- 📉 Inconsistent presence on Dev.to
After:
- ⚡ Automatic publishing in ~30 seconds
- ✅ Zero manual intervention required
- 🔄 Updates synchronized automatically
- 📈 Consistent cross-posting to Dev.to
- 🎯 More time for writing, less for publishing
Lessons Learned
Building this action taught me several valuable lessons:
1. Start with a Script, Then Package It
I initially built a TypeScript script (publish-to-devto.ts) that worked locally. Once it was stable, I packaged it as a GitHub Action. This iterative approach made debugging much easier.
2. Frontmatter Mapping is Tricky
Hugo and Dev.to use different field names and formats. Creating a robust mapping layer required careful testing with various post formats.
3. Idempotency Matters
The action needed to handle both new posts and updates gracefully. Checking for existing articles by canonical URL was crucial.
4. Documentation is Key
I created multiple documentation files:
-
README.md- Overview and quick start -
GETTING_STARTED.md- 5-minute setup guide -
SETUP.md- Comprehensive instructions -
HUGO_COMPATIBILITY.md- Hugo-specific details -
API_KEY_SETUP.md- Security best practices
This made the action accessible to users with different needs.
5. Community Feedback is Invaluable
Publishing the action to the GitHub Marketplace exposed it to real-world use cases I hadn't considered. User feedback helped improve error handling and edge cases.
Future Enhancements
While the action works great, there's always room for improvement:
- Batch Publishing: Support publishing multiple posts in a single action invocation
- Dry Run Mode: Preview what would be published without actually doing it
- Custom Field Mapping: Allow users to configure their own frontmatter mappings
- Image Upload: Automatically upload local images to Dev.to
- Analytics Integration: Track publishing metrics
- Multi-Platform Support: Extend to Medium, Hashnode, etc.
- Additional Shortcode Support: Transform other Hugo shortcodes (YouTube, Twitter embeds, etc.)
Conclusion
Building hugo2devto solved a real problem I faced as a technical blogger: the friction of cross-posting content. By automating the process through GitHub Actions, I eliminated manual work, reduced errors, and made it effortless to maintain a presence on Dev.to.
The action is open source and available for anyone to use. Whether you're running a Hugo blog, a Jekyll site, or any markdown-based platform, the core concepts apply: automate the boring stuff so you can focus on writing great content.
If you're interested in trying it out or contributing, check out the repository:
Repository: https://github.com/Walsen/hugo2devto
GitHub Marketplace: https://github.com/marketplace/actions/hugo-to-dev-to-publisher
The future of technical blogging is automated, and I'm excited to see where this journey leads. Happy blogging!
Links
- Action Repository: https://github.com/Walsen/hugo2devto
- My Blog: https://blog.walsen.website
- Dev.to Profile: https://dev.to/w4ls3n
- Dev.to API Documentation: https://developers.forem.com/api
Top comments (1)
Really useful. It saves me a lot of time. The future of technical blogging is automated I completely agree with that