The seo-farm cross-publish pipeline uses Dev.to, Hashnode, and Bluesky as its default targets: 100 of the 103 article files in the repo right now declare all three in publish_to, and the three oldest (articles 01-03) declare only Dev.to and Hashnode. "Targets" is the honest word, though — 78 of those articles have a Dev.to URL, 78 have a Bluesky URL, and only 19 have a Hashnode URL. The order isn't decoration: each step depends on the previous one completing and writing a URL into the article's in-memory frontmatter. 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 the article title 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 loop was sequential from the first version, but the canonical resolution wasn't. The original hashnode.ts had only one branch:
// old code
if (fm.canonical_url) input.originalArticleURL = fm.canonical_url;
I almost never set canonical_url by hand, so that branch almost never fired, and Hashnode cross-posts went up with no canonical pointer at all — which makes Hashnode canonical by default. The fix, a week after the publisher first shipped, was the published_urls.devto fallback shown above.
Sequential ordering is what makes that fallback possible: Dev.to has to have run and written its URL into the in-memory frontmatter before Hashnode reads it.
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 103 articles across the pipeline. Monthly I scan for articles missing one platform:
grep -L '^published_urls:.*"hashnode"' content/articles/*.md
(The naive version — grep -rL '"hashnode"' content/articles/*.md — returns nothing, because every article mentions hashnode in its publish_to list. The pattern has to be anchored to the published_urls line.)
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, and a body of just the title plus the Dev.to link. When a card is attached, composePost deliberately leaves the description out of the body — the card already renders it. The plain-text excerpt only appears in the fallback case where no card image is available. The card is a 1080x1350 portrait PNG.
What happens if the publisher is killed mid-run?
saveArticle runs once per article, after that article's platform loop finishes — so every article that completed its loop before the kill has its URLs on disk, and those platforms are skipped on the next run. The article that was mid-loop when the process died is the one that hurts: its successful publishes only existed in the in-memory frontmatter, so the file never records them and the next run will try those platforms again and can duplicate that one article's posts. There's no transactional guarantee; the published_urls check only prevents duplicates for URLs that actually made it to disk.
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)