How I built a reusable GitHub Action to automate publishing Hugo blog posts to Dev.to, eliminating manual cross-posting and keeping content synchronized across platforms.
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.
Let me paint a picture of the traditional workflow:
This process has several problems:
The ideal solution would:
This is exactly what hugo2devto does.
The action is built with TypeScript and runs on Node.js 20. Here’s the high-level architecture:
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 → titledescription → descriptiontags → tags (limited to 4)series → serieseyecatch / cover_image → main_imagedraft → published (inverted)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/
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.
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.
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.
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);
}
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'
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:
fail-fast: false so one failure doesn’t stop othersYou 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'
Since implementing this action on my blog, the results have been transformative:
Before:
After:
Building this action taught me several valuable lessons:
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.
Hugo and Dev.to use different field names and formats. Creating a robust mapping layer required careful testing with various post formats.
The action needed to handle both new posts and updates gracefully. Checking for existing articles by canonical URL was crucial.
I created multiple documentation files:
README.md - Overview and quick startGETTING_STARTED.md - 5-minute setup guideSETUP.md - Comprehensive instructionsHUGO_COMPATIBILITY.md - Hugo-specific detailsAPI_KEY_SETUP.md - Security best practicesThis made the action accessible to users with different needs.
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.
While the action works great, there’s always room for improvement:
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!
Keyboard Shortcuts
| Command | Function |
|---|---|
| ? (Shift+/) | Bring up this help modal |
| g+h | Go to Home |
| g+p | Go to Posts |
| g+e | Open Editor page on GitHub in a new tab |
| g+s | Open Source page on GitHub in a new tab |
| r | Reload page |