DEV Community

Cover image for GitHub Action to Publish Hugo Posts to Dev.to
Sergio D. Rodríguez Inclán
Sergio D. Rodríguez Inclán

Posted on • Originally published at blog.walsen.website

GitHub Action to Publish Hugo Posts to Dev.to

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:

  1. Write your post in Hugo with YAML frontmatter
  2. Build and deploy your Hugo site
  3. Open Dev.to in your browser
  4. Copy-paste your markdown content
  5. Manually configure:
    • Title
    • Tags (max 4)
    • Cover image
    • Canonical URL
    • Published/draft status
    • Series information
  6. Preview and publish
  7. 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:

  1. Detect when a Hugo post is created or updated
  2. Automatically extract frontmatter metadata
  3. Publish or update the post on Dev.to
  4. Set the correct canonical URL
  5. Handle tags, cover images, and series
  6. 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:

Mermaid Diagram

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"
---
Enter fullscreen mode Exit fullscreen mode

It automatically maps these fields to Dev.to's API format:

  • titletitle
  • descriptiondescription
  • tagstags (limited to 4)
  • seriesseries
  • eyecatch / cover_imagemain_image
  • draftpublished (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}/`
Enter fullscreen mode Exit fullscreen mode

For example, a post at content/en/posts/my-post.md becomes:

https://blog.walsen.website/en/posts/my-post/
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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) -->
![Mermaid Diagram](https://mermaid.ink/img/base64encodeddiagram)
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

This workflow:

  1. Detects which markdown files changed in the last commit
  2. Converts them to a JSON array
  3. Uses a matrix strategy to publish multiple posts in parallel
  4. Sets fail-fast: false so 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'
Enter fullscreen mode Exit fullscreen mode

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:

  1. Batch Publishing: Support publishing multiple posts in a single action invocation
  2. Dry Run Mode: Preview what would be published without actually doing it
  3. Custom Field Mapping: Allow users to configure their own frontmatter mappings
  4. Image Upload: Automatically upload local images to Dev.to
  5. Analytics Integration: Track publishing metrics
  6. Multi-Platform Support: Extend to Medium, Hashnode, etc.
  7. 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

Top comments (1)

Collapse
 
libialany profile image
LibiaLany

Really useful. It saves me a lot of time. The future of technical blogging is automated I completely agree with that