The seo-farm cross-publish pipeline posts every article to Dev.to, Hashnode, and Bluesky. The order isn't decoration: each step depends on the previous one completing and writing a URL back to the article file. Getting that chain wrong meant Hashnode becoming canonical for articles where I wanted Dev.to to own the URL — something I only noticed after about a dozen cross-posts had already gone live with the wrong canonical signal.
This is how I built the chain correctly, what I had wrong before, and the specific code decisions that make it work.
Why ordering matters for cross-posts
The SEO risk of cross-posting to multiple platforms is duplicate content. Google needs a canonical signal to know which version to index. Dev.to has strong domain authority among developer content. Hashnode's originalArticleURL field lets you explicitly tell it "this post originated elsewhere." Bluesky is a social link — it doesn't compete for indexing, but the URL it links to should point to the primary post.
The ordering dependency falls out of these decisions:
-
Dev.to publishes first and returns the live URL (
https://dev.to/morinaga/...). -
Hashnode publishes second, using the Dev.to URL as
originalArticleURLin the GraphQL mutation. This tells Hashnode — and by extension, whatever Google crawls — that Dev.to is the canonical source. -
Bluesky posts last, linking to whichever source URL is already available (
published_urls.devtopreferred,published_urls.hashnodeas fallback).
Run them out of order and the chain breaks in specific ways. Publish Hashnode first and it has no Dev.to URL to point at — so it becomes canonical by default. Publish Bluesky first and there's nothing for it to link to.
Frontmatter as publication state
The article file carries its own publication history in the frontmatter. After each successful publish, the URL gets written back to the file:
published_urls:
devto: "https://dev.to/morinaga/some-article-slug-xxxx"
hashnode: "https://yusuke.hashnode.dev/some-article-slug"
bluesky: "https://bsky.app/profile/morinaga2222.bsky.social/post/xxxxx"
published_at:
devto: "2026-07-12T08:00:00.000Z"
hashnode: "2026-07-12T08:00:02.000Z"
bluesky: "2026-07-12T08:00:04.000Z"
The publisher holds a dirty flag — let dirty = false — and only calls saveArticle(article) after the article loop if something actually changed. This avoids meaningless git diffs when an article's platforms are already all published. On the next GitHub Actions run, the published-URL check (if (existing && !values.force)) skips any platform that already has a URL.
This frontmatter-as-state pattern means the pipeline is automatically idempotent. You can re-run the publish step and it will only fill gaps — platforms that didn't complete last time, or new platforms added to publish_to later.
Setting canonical on Hashnode
Hashnode exposes canonical URL control via the originalArticleURL field in the publishPost GraphQL mutation. My hashnode.ts resolves the canonical in three priority steps:
// Cross-post canonical: prefer explicit canonical_url, else use devto URL if already published.
// This avoids Hashnode being treated as the canonical source when devto post exists.
if (fm.canonical_url) {
input.originalArticleURL = fm.canonical_url;
} else if (fm.published_urls?.devto) {
input.originalArticleURL = fm.published_urls.devto;
}
-
Explicit
canonical_urlin frontmatter: The author can always override. This covers cases like publishing to a personal blog first and then cross-posting everywhere else. -
published_urls.devtofrom this run: If Dev.to published successfully (step 1 of the chain), that URL is already written to the frontmatter object in memory — not yet saved to disk, but available inarticle.frontmatter. When hashnode.ts receives the article, it readsfm.published_urls?.devtoand finds it. -
Neither: Hashnode omits
originalArticleURLentirely and becomes canonical by default. This is the failure mode I was hitting before.
The in-memory availability matters. The publisher reads the file once (loadArticle(filePath)) and then mutates article.frontmatter.published_urls as each platform succeeds. Hashnode can read the Dev.to URL from the in-memory object even before saveArticle has written it to disk. This is why the chain works without any inter-process coordination.
The Bluesky dependency check
Bluesky posts a short excerpt with a link to the primary article. The publisher checks before attempting it:
if (
platform === "bluesky" &&
!article.frontmatter.published_urls.devto &&
!article.frontmatter.published_urls.hashnode
) {
totalDeferred++;
console.log(` ⏳ bluesky: no Dev.to/Hashnode source URL yet, deferred to next run`);
continue;
}
Two things to note here:
The check is !devto && !hashnode, not just !devto. If Dev.to is rate-limited mid-run and skips several articles, those articles will have a Hashnode URL but no Dev.to URL by the time Bluesky runs. The || hashnode fallback means Bluesky can still post with a Hashnode link rather than deferring again. Dev.to is preferred when both exist — publishToBluesky uses a (published_urls.devto ?? published_urls.hashnode) expression for the link URL.
This is a defer, not a failure. Bluesky skipping doesn't turn the job red. On the next run, Dev.to and Hashnode platforms are already published (skipped via the existing check) and Bluesky finds its source URL and posts. This pattern came from fixing the error handling after an expired Hashnode token took down the whole pipeline.
The platform ordering requirement
The publish_to frontmatter field controls which platforms receive the article. The spec requires ["devto", "hashnode", "bluesky"] in that order:
publish_to: ["devto", "hashnode", "bluesky"]
The publisher iterates targets sequentially, so declaration order is execution order. If someone writes ["bluesky", "devto", "hashnode"], Bluesky would attempt to run before Dev.to has published and immediately defer — the canonical URL chain is built on sequential ordering, not concurrency.
The default platforms array in index.ts enforces the correct order when publish_to is missing or empty:
const DEFAULT_PLATFORMS: Platform[] = ["devto", "hashnode", "bluesky"];
This makes it hard to accidentally break the chain. You'd have to explicitly write the platforms in the wrong order in frontmatter to hit the problem — and the Bluesky defer check is a safety net even then.
Per-platform rate limiting and spacing
The publisher has two mechanisms to stay under platform rate limits across a batch of articles:
const MAX_NEW_PER_RUN = intEnv("MAX_NEW_PUBLISHES_PER_RUN", 8, 1);
const PUBLISH_SPACING_MS = intEnv("PUBLISH_SPACING_MS", 1500, 0);
MAX_NEW_PER_RUN caps new publishes per platform per run. Crucially, this cap is tracked separately per platform via newByPlatform:
const newByPlatform = new Map<Platform, number>();
// ...
newByPlatform.set(platform, (newByPlatform.get(platform) ?? 0) + 1);
Dev.to hitting 8 publishes doesn't stop Hashnode from continuing. Both have their own counters. This matters when catching up after a gap — I've had scenarios where 20 articles queued and wanted them draining on all platforms in parallel across runs, not serialized by a global cap.
PUBLISH_SPACING_MS = 1500 adds a 1.5-second pause after each successful publish. Dev.to's rate limit is "a few articles per minute" at the article-creation endpoint; spacing by 1.5s distributes a 8-article batch across 12 seconds, which stays comfortably under the limit.
What I got wrong first
The original pipeline published to all platforms in parallel:
// old code (don't do this)
await Promise.all(targets.map(platform => publishOne(article, platform)));
This created a race condition: Hashnode and Dev.to were both in flight at the same time. Whichever completed first had no URL from the other platform. Hashnode published without originalArticleURL about half the time. I noticed when checking Google Search Console weeks later — Hashnode was being indexed as canonical for a handful of articles I'd explicitly wanted Dev.to to own.
The fix was to switch to sequential iteration and use the in-memory frontmatter mutation pattern. Slower per-run (sequential instead of parallel), but the canonical chain is now predictable.
What I'd do differently
Two things I'd add if building this again:
Pre-flight token check. Before iterating any articles, make one authenticated request to each platform (a GET, not a POST) to verify all API keys are valid. Surface failures immediately rather than discovering them on the third article when the Hashnode token has quietly expired. The expired-token debugging session took longer than it needed to.
Platform-level summary post. After all articles in a run have published, emit a single Bluesky thread ("3 new articles this week") rather than one Bluesky post per article. Three individual Bluesky posts in 5 minutes looks like a bot. I'm handling this separately with the scheduled Bluesky queue rather than in the publisher itself.
Current state
The three directory sites launched in April now have 102 articles across the pipeline. Monthly I scan for articles missing one platform:
grep -rL '"hashnode"' content/articles/*.md | grep published_urls
Those are typically articles where Hashnode had a transient error or the pipeline ran without HASHNODE_TOKEN set. The frontmatter-as-state pattern means I can fill gaps by running:
pnpm post:article content/articles/missing-article.md --platform hashnode
The --force flag would re-publish and potentially duplicate if the article is already there, so I check manually first. I'll publish actual gap counts in 30 days when the sample is meaningful.
FAQ
What if Dev.to fails — does Hashnode still publish?
Yes. The sequential loop continues to Hashnode even if Dev.to fails. Hashnode simply publishes without originalArticleURL, making it the canonical. You can manually set canonical_url in frontmatter to override this after the fact and re-run with --platform hashnode --force.
Can I override the canonical URL per article?
Set canonical_url: https://... in the article's frontmatter. This takes highest priority in the chain and is passed to both Dev.to (via the canonical_url REST field) and Hashnode (via originalArticleURL).
What does the Bluesky post look like?
A Bluesky summary card with a title-card image generated from the article's summary_data frontmatter, plus a plain-text excerpt and a link to the Dev.to URL. The card is a 1080x1350 portrait PNG rendered at deploy time.
What happens if the publisher is killed mid-run?
The saveArticle call at the end of each article's loop writes whatever was published before the kill. On the next run, those platforms are skipped. Partially published articles will catch up article-by-article — there's no transactional guarantee, but the published_urls check prevents duplicates on already-published platforms.
Part of an ongoing 6-month experiment running three AI-curated directory sites. The technical claims here are real; this article was AI-assisted.
Top comments (0)