DEV Community

FOLASAYO SAMUEL OLAYEMI
FOLASAYO SAMUEL OLAYEMI

Posted on • Originally published at saintvandora.hashnode.dev

How to Auto-Sync Your Hashnode Blog to Dev.to Using GitHub Actions (2026 Guide)

If you've been trying to cross-post from Hashnode to Dev.to recently, you've probably hit a wall. Dev.to's RSS importer rejects Hashnode feeds, Hashnode's GraphQL API went paid in May 2026, and every proxy service either blocks GitHub Actions IPs or returns garbage. This guide shows the approach that actually works in 2026.

The Problem

There are three broken paths you'll waste time on before finding what works:

  • Dev.to RSS Import (dashboard/feed_imports): rejects Hashnode's RSS feed with "Feed url is not a valid RSS feed URL", even though the feed is perfectly valid XML
  • Hashnode GraphQL API: redirects to a paid-access changelog page since May 2026
  • RSS proxy services (rss2json, AllOrigins): blocked by GitHub Actions IPs or return HTML instead of JSON

The working solution: use Hashnode's built-in GitHub backup as the data source, read posts directly from that repo via the GitHub API, and push them to Dev.to via their API.

Prerequisites

  • A Hashnode blog with published posts
  • A Dev.to account
  • A GitHub account

Step 1: Enable Hashnode GitHub Backup

Hashnode can automatically push all your posts as Markdown files to a GitHub repository.

  1. Go to your Hashnode blog dashboard
  2. Find GitHub or Integrations in the sidebar
  3. Connect your GitHub account and point it to a repository (e.g. your-username/repo)
  4. Hashnode will push all existing and future posts as .md files

Once set up, verify the repo exists and contains your posts at github.com/your-username/repo.

Step 2: Get Your Dev.to API Key

  1. Go to dev.to/settings/extensions
  2. Scroll down to DEV API Keys
  3. Enter a name (e.g. "Hashnode Sync") and click Generate API Key
  4. Copy the key — you won't see it again

Step 3: Get a GitHub Personal Access Token

The default GITHUB_TOKEN in GitHub Actions is scoped to the current repo only. Since your Hashnode backup is in a different repo, you need a PAT with cross-repo read access.

  1. Go to github.com/settings/tokens
  2. Click Generate new token (classic)
  3. Give it a name (e.g. "Hashnode Sync")
  4. Check the repo scope
  5. Click Generate token and copy it

Step 4: Create the Sync Repository

Create a new GitHub repository called hashnode-devto-sync (or any name you prefer). This is where the workflow and sync script will live.

Step 5: Add GitHub Secrets

In your hashnode-devto-sync repo, go to Settings → Secrets and variables → Actions → New repository secret and add:

Name Value
DEVTO_API_KEY Your Dev.to API key from Step 2
GH_PAT Your GitHub PAT from Step 3

Step 6: Create sync.js

Create a file called sync.js in the root of your repo:

const fetch = require('node-fetch');

const DEVTO_API_KEY = process.env.DEVTO_API_KEY;
const GITHUB_TOKEN = process.env.GITHUB_TOKEN;
const BACKUP_REPO = 'your-username/Hashnode_Store'; // ← update this

const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));

async function getHashnodePosts() {
  const res = await fetch(
    `https://api.github.com/repos/${BACKUP_REPO}/contents/`,
    {
      headers: {
        'Authorization': `Bearer ${GITHUB_TOKEN}`,
        'Accept': 'application/vnd.github+json'
      }
    }
  );
  const files = await res.json();

  if (!Array.isArray(files)) {
    throw new Error(`GitHub API error: ${JSON.stringify(files)}`);
  }

  const mdFiles = files.filter(f => f.name.endsWith('.md') && f.name !== 'README.md');
  console.log(`Found ${mdFiles.length} markdown files`);

  const posts = [];
  for (const file of mdFiles) {
    const fileRes = await fetch(file.download_url);
    const markdown = await fileRes.text();

    // Extract title from frontmatter, falling back to first heading
    const titleMatch = markdown.match(/^title:\s*["']?(.+?)["']?\s*$/m);
    const urlMatch = markdown.match(/^originalArticleURL:\s*(.+?)\s*$/m)
                  || markdown.match(/^canonical_url:\s*(.+?)\s*$/m);

    const title = titleMatch?.[1]
      || markdown.match(/^##?\s+(.+)$/m)?.[1]
      || file.name.replace('.md', '');

    const canonicalUrl = urlMatch?.[1] || '';

    // Strip YAML frontmatter from body
    const body = markdown.replace(/^---[\s\S]*?---\n/, '').trim();

    posts.push({ title, link: canonicalUrl, content: body });
  }

  return posts;
}

async function getExistingDevToPosts() {
  const res = await fetch('https://dev.to/api/articles/me/all?per_page=1000', {
    headers: { 'api-key': DEVTO_API_KEY }
  });
  return res.json();
}

async function sync() {
  const posts = await getHashnodePosts();
  console.log(`Fetched ${posts.length} posts from Hashnode`);

  const existing = await getExistingDevToPosts();
  console.log(`Found ${existing.length} existing Dev.to posts`);

  // Dedup by both canonical URL and title to avoid duplicates
  const existingCanonicals = new Set(existing.map(p => p.canonical_url).filter(Boolean));
  const existingTitles = new Set(existing.map(p => p.title?.toLowerCase().trim()).filter(Boolean));

  let imported = 0;
  let skipped = 0;

  for (const post of posts) {
    if (existingCanonicals.has(post.link) || existingTitles.has(post.title?.toLowerCase().trim())) {
      console.log(`Skipping (exists): ${post.title}`);
      skipped++;
      continue;
    }

    // 2s delay between posts to stay under Dev.to rate limits
    await sleep(2000);

    try {
      const res = await fetch('https://dev.to/api/articles', {
        method: 'POST',
        headers: {
          'api-key': DEVTO_API_KEY,
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({
          article: {
            title: post.title,
            body_markdown: post.content,
            published: false,          // lands as draft — you review before publishing
            canonical_url: post.link || undefined
          }
        })
      });

      if (res.status === 429) {
        console.log('Rate limited — waiting 30s...');
        await sleep(30000);
        continue;
      }

      const result = await res.json();
      console.log(`Imported (${res.status}): ${post.title}`);
      imported++;
    } catch (e) {
      console.error(`Failed: ${post.title}${e.message}`);
    }
  }

  console.log(`Done. Imported: ${imported}, Skipped: ${skipped}`);
}

sync().catch(console.error);
Enter fullscreen mode Exit fullscreen mode

Important: Update BACKUP_REPO on line 5 to match your own GitHub username and repo name.

Step 7: Create the GitHub Actions Workflow

Create .github/workflows/sync.yml:

name: Sync Hashnode to Dev.to

on:
  workflow_dispatch:        # run manually from GitHub Actions UI
  schedule:
    - cron: '0 * * * *'    # also runs every hour automatically

jobs:
  sync:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6

      - name: Setup Node
        uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Install deps
        run: npm install node-fetch@2

      - name: Sync posts
        env:
          DEVTO_API_KEY: ${{ secrets.DEVTO_API_KEY }}
          GITHUB_TOKEN: ${{ secrets.GH_PAT }}
        run: node sync.js
Enter fullscreen mode Exit fullscreen mode

Note node-fetch@2; version 3 is ESM-only and won't work with require().

Step 8: Run It

  1. Go to your repo on GitHub
  2. Click the Actions tab
  3. Select Sync Hashnode to Dev.to
  4. Click Run workflow

Watch the logs. You should see something like:

Found 93 markdown files
Fetched 93 posts from Hashnode
Found 0 existing Dev.to posts
Imported (201): How to Resolve Docker Not Found Issues on macOS
Imported (201): How to Easily Share Your Swagger Documentation with Your Team
...
Done. Imported: 93, Skipped: 0
Enter fullscreen mode Exit fullscreen mode

Your posts will appear as drafts in your Dev.to dashboard. Review and publish them one by one.

How Deduplication Works

Every time the workflow runs, it:

  1. Fetches all your Hashnode posts from the GitHub backup repo
  2. Fetches all your existing Dev.to posts (published + drafts)
  3. Builds two sets: existing canonical URLs and existing titles
  4. Skips any Hashnode post whose canonical URL or title already exists on Dev.to
  5. Only imports genuinely new posts

This means you can safely let the hourly schedule run, it won't create duplicates.

SEO Note

All imported posts have canonical_url set to your Hashnode article URL. This tells Google that Hashnode is the original source, so SEO credit stays with your Hashnode blog. Dev.to gives you the community reach; Hashnode keeps the domain authority.

Troubleshooting

"Status code 403" when fetching RSS: Hashnode blocks GitHub Actions IPs on their RSS endpoint. This is why we use the GitHub backup approach instead.

"GraphQL API paid access" redirect: Hashnode's free GraphQL API was retired in May 2026. The GitHub backup is the free alternative.

"Retry later" from Dev.to: You're hitting their rate limit. The 2s delay between posts and 30s pause on 429 responses handle this automatically.

Posts imported with filename as title: Your older Hashnode posts may not have a title: field in their frontmatter. The script falls back to the first # or ## heading in the content. If that also fails, you'll need to manually edit the title in the Dev.to draft before publishing.

Top comments (0)