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.
- Go to your Hashnode blog dashboard
- Find GitHub or Integrations in the sidebar
- Connect your GitHub account and point it to a repository (e.g.
your-username/repo) - Hashnode will push all existing and future posts as
.mdfiles
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
- Go to
dev.to/settings/extensions - Scroll down to DEV API Keys
- Enter a name (e.g. "Hashnode Sync") and click Generate API Key
- 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.
- Go to
github.com/settings/tokens - Click Generate new token (classic)
- Give it a name (e.g. "Hashnode Sync")
- Check the
reposcope - 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);
Important: Update
BACKUP_REPOon 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
Note node-fetch@2; version 3 is ESM-only and won't work with require().
Step 8: Run It
- Go to your repo on GitHub
- Click the Actions tab
- Select Sync Hashnode to Dev.to
- 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
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:
- Fetches all your Hashnode posts from the GitHub backup repo
- Fetches all your existing Dev.to posts (published + drafts)
- Builds two sets: existing canonical URLs and existing titles
- Skips any Hashnode post whose canonical URL or title already exists on Dev.to
- 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)