The conclusion first: don't embed platform-specific URLs in article bodies. Use a stable /articles/slug/ format in your Markdown source and resolve them to real URLs at publish time. When the target hasn't been published yet, unwrap the link to plain text instead of shipping a dead href.
That's the system I built in packages/publish/src/links.ts. This article explains what forced me to it, how each piece works, and where it still falls short.
The problem cross-posting creates for internal links
I cross-post every article to Dev.to, Hashnode, and Bluesky from a single Markdown source. The same file lands on three platforms with different URL structures.
Dev.to gives a URL like https://dev.to/mori7ga2222/article-slug-12345. Hashnode gives https://blog.morinaga.dev/article-slug. If article A links to article B and I embed the Dev.to URL, that link looks wrong in the Hashnode version — it sends readers off to Dev.to rather than to the Hashnode post they might currently be reading. Not a fatal issue, but not clean.
More importantly: I often write article A before publishing article B. A needs to reference B. At write time, B has no URL on any platform. Embedding https://dev.to/mori7ga2222/not-yet-published isn't possible.
The naive answer is to go back and update A after B goes live. At 130+ articles with interconnected references, doing that manually is not something I want to think about.
The stable link format: /articles/slug/
Every internal reference in my Markdown uses the format /articles/some-slug/. This path does not resolve on any platform — neither Dev.to nor Hashnode serves anything at /articles/. The links in source Markdown are intentionally dead until publish time.
The slug comes from the filename. A file named 120-2026-07-23-two-publish-pipeline-slug-resolution-edge-cases.md has slug two-publish-pipeline-slug-resolution-edge-cases. So when I reference that article in another piece, I write:
the [two slug resolution edge cases](https://dev.to/morinaga/two-publish-pipeline-slug-resolution-bugs-a-code-review-caught-before-18-links-went-dead-4oo1) I described in July
The format has two parts: the stable cross-reference in source, and the rewrite that happens at publish time. Let me cover each.
Extracting the slug from the filename
The extraction function is:
export function slugFromFilename(name: string): string | null {
const m = /^\d+-(?:\d{4}-\d{2}-\d{2}-)?(.+)\.md$/.exec(name);
return m ? m[1] : null;
}
Two filename formats exist in the repo. Most articles are numbered with a date: 120-2026-07-23-some-slug.md. The earliest articles, written before I added dating to the convention, are just 01-three-sites-experiment.md. The regex handles both by making the date segment optional.
The slug is whatever comes after the number (and optional date): everything from the first letter of the content segment through to .md. It's lowercase, hyphen-separated, and never contains a date. That means it doesn't change if I backdate an article or add one out of sequence. The slug is stable as long as the filename is stable.
Building the URL map from published_urls
After publishing an article, the workflow writes the platform URL back into the article's frontmatter as published_urls: {"devto": "https://dev.to/mori7ga2222/article-slug-12345"}.
The buildSlugMap function reads all articles at startup and constructs a Map<slug, url>:
export async function buildSlugMap(articlesDir: string): Promise<Map<string, string>> {
const map = new Map<string, string>();
const names = await readdir(articlesDir);
for (const name of names) {
const slug = slugFromFilename(name);
if (!slug) continue;
const raw = await readFile(join(articlesDir, name), "utf8");
const url = publishedUrlFromFrontmatter(raw);
if (url) map.set(slug, url);
}
return map;
}
publishedUrlFromFrontmatter pulls the URL from the published_urls field with a simple regex rather than a full YAML parse — the field is always on a single line and formatted consistently. Dev.to wins the tie-break: urls.devto ?? urls.hashnode ?? null. The reasoning is that Bluesky posts link to the Dev.to version, so Dev.to is the de facto primary. Keeping all internal links point there makes the referrer chain consistent.
If an article hasn't been published yet, publishedUrlFromFrontmatter returns null and buildSlugMap skips it. The slug simply won't appear in the map.
Rewriting at publish time
rewriteInternalLinks runs over the article body just before it's sent to each platform:
const INTERNAL_LINK = /\[([^\]]*)\]\(\/articles\/([a-z0-9][a-z0-9-]*)\/?(#[^)]*)?\)/g;
export function rewriteInternalLinks(
body: string,
resolveSlug: (slug: string) => string | null,
): string {
return body.replace(INTERNAL_LINK, (_m, text, slug, anchor) => {
const url = resolveSlug(slug);
return url ? `[${text}](${url}${anchor ?? ""})` : text;
});
}
Three paths:
- Slug resolves → replace with real URL, preserving any anchor fragment.
-
Slug not in map → unwrap to plain text.
referenced articlebecomesreferenced article. - Article not yet published → same as above.
The fallback to plain text is the deliberate choice here. A broken link is worse than no link — especially on Dev.to where /articles/slug/ explicitly does not resolve. Shipping text would produce a clickable link that 404s. The source article reads naturally either way: the text flows, there's just no hyperlink yet.
The regex also captures #section-id anchors: /articles/slug/#my-heading. When the slug resolves, the anchor is appended: [text](https://dev.to/mori7ga2222/article-12345#my-heading). This is mostly aspirational — anchor IDs are platform-generated from heading text and the format differs between Dev.to and Hashnode. I don't try to normalize them.
The bulk-publish registration problem
If I publish ten articles in a single run, article 5 might reference article 2, and article 2 gets published before article 5 is processed. The slug map was built at the start of the run, before article 2 had a Dev.to URL. Article 5's body would see an empty map entry for article 2 and unwrap its link to plain text.
The solution: the Map is mutable and is updated in-flight. After publishing article 2 and receiving its Dev.to URL, the caller does:
slugMap.set(slugFromFilename(article2.filePath), devToUrl);
When article 5's body is rewritten moments later, the map contains article 2's URL. No second pass, no file read. The returned Map from buildSlugMap is the callers' shared registry — they update it as articles go live.
This matters for the canonical URL behavior I covered separately: Dev.to is published first, then Hashnode with Dev.to's URL as its originalArticleURL. During a batch run, article A gets a Dev.to URL, registers it in the map, then when article B is rewritten for Hashnode, it links to article A's Dev.to version rather than a dead path.
The two slug resolution edge cases I ran into while building this involved filenames with identical slugs — which the numbering convention prevents in practice but didn't prevent in test fixtures.
What I'd do differently
Re-publish is required to pick up resolved links. If article B was published before article A went live, B's copy on Dev.to and Hashnode has article A as plain text with no link. The only way to upgrade it is to re-publish B. I don't have a mechanism for that today. For most articles this is acceptable — the text still makes sense — but it's a genuine limitation.
Anchor IDs aren't normalized. A heading ## Cross-Platform Quirks might render as #cross-platform-quirks on Dev.to and #cross-platform-quirks-a1b2 on Hashnode. The link I write targets one form. For now I mostly avoid anchor links in cross-post content and prefer linking to the top of an article.
Slug stability depends on filename stability. If I rename a file to fix a typo or change the slug, every article that referenced the old slug now links to nothing (or to plain text). The cross-publish token expiry taught me how fragile pipeline dependencies are: the slug format is one more implicit contract that can quietly break. I've kept a naming policy (no renaming after first publish) but it's not enforced by code.
The published_urls field is write-once per run. The workflow records one URL per platform. If an article is edited and re-published at a different URL, the field isn't updated. This hasn't happened yet; Dev.to and Hashnode assign stable URLs at creation time.
The three things this pattern gives you
Single-source linking. The article's Markdown has one set of links. There's no per-platform variant of article A with different hrefs for Hashnode vs. Dev.to. Three Hashnode API behaviors are real differences I had to work around at the API level; internal linking is one thing I don't want to maintain separately per platform.
Forward references work. I can write article A that links to article B before B is published. At publish time, if B has been published (same or prior batch), the link resolves. If not, it degrades to plain text. No manual follow-up needed.
The Bluesky JSONL queue posts Dev.to links for the same articles. Having all internal links also point to Dev.to keeps one URL as the canonical target regardless of which platform a reader arrives from.
FAQ
Why not just use the Dev.to URL directly if Dev.to is always first?
Because at write time the Dev.to URL doesn't exist yet. The article needs to be committed, the workflow needs to run, and Dev.to's API needs to respond before I have the URL. Writing the article and publishing it aren't the same moment. The /articles/slug/ format decouples them.
Does the rewrite run once or per-platform?
Once per publish target. Bluesky gets a different rewrite from Dev.to (Bluesky posts are plain text, not Markdown), but the internal link logic runs before the platform-specific formatter. The rewritten body is what each platform receives.
What does the Bluesky post look like when the slug doesn't resolve?
Plain text — the same as any other platform. Bluesky doesn't render Markdown anyway; the body goes through a text-extraction pass before being turned into a Bluesky post with facets. A missing link becomes missing anchor text in the Bluesky copy, which is acceptable for Bluesky's format.
What happens if a slug appears twice in the article directory?
buildSlugMap iterates directory entries in filesystem order. The last entry with a given slug wins. In practice this shouldn't happen: the filename numbering produces unique slugs. The two slug resolution edge cases were test-fixture collisions, not production ones.
Can I link to a specific heading?
Yes: /articles/some-slug/#heading-id. The anchor is preserved in the rewritten URL. Whether the ID is correct on the target platform is your problem — I don't validate anchors against the rendered HTML.
Part of an ongoing 6-month experiment running three AI-curated directory sites. The technical claims here are real; this article was AI-assisted.