One-off tip

Support checkout

  1. 1Choose amount
  2. 2Payment
  3. 3Thank you

Choose amount

Pick the level of support that feels right. You can keep it simple or enter a custom amount, then continue to secure payment.

Choose a one-off amount
Automating Dev.to cross-posting from an Astro blog
Cover image: Four hands connecting colourful puzzle pieces with symbols
astroengineeringdevtoautomation

Automating Dev.to cross-posting from an Astro blog

A Node.js script that reads your frontmatter, sets the canonical URL, and publishes or updates in one command

Published
22 September 2026
Read time
15 min read
SeriesPart of How this blog was built: documenting every decision that shaped this site.

Dev.to has a large audience that won’t find your personal blog through search. Cross-posting gets your content in front of them, but only if you set up the canonical URL correctly. Skip that step and you risk training search engines to prefer the Dev.to copy over your own site.

This post walks through the cross-posting script this blog uses: a single Node.js file that reads frontmatter from your markdown posts, converts a few blog-specific features into Dev.to-friendly markdown, builds an article payload, shows a preview, and asks for confirmation before publishing or updating.

How cross-posting and canonical URLs work

When you publish the same content to two URLs, search engines need a signal for which one to treat as authoritative. The canonical URL (<link rel="canonical">) provides that signal.

Dev.to supports a canonical_url field on its API. When set, Dev.to renders it as the canonical link in the page <head>, pointing back to your blog. Search engines follow that signal and credit your domain for the content, not Dev.to.

Never cross-post without setting canonical_url. Without it, Dev.to’s version may outrank your own.

Architecture

Setting up

Get an API key from dev.to/settings/extensions under DEV Community API Keys. Add it, along with your site’s public URL, to .env:

Terminal window
DEVTO_API_KEY=your_key_here
SITE_URL=https://sourcier.uk

The script reads .env automatically if it exists, using Node 20.12’s built-in process.loadEnvFile.

Running it

Terminal window
pnpm crosspost:devto

The script lists published, non-future posts sorted newest-first and prompts you to pick one from a dropdown built with @inquirer/prompts:

? Select a post to cross-post: (Use arrow keys)
❯ reactions-netlify-blobs-astro
dark-light-theme-toggle
comments-system
...
─────────────────────────────────────────
Title : Adding emoji reactions to your Astro blog with Netlify Blobs
Canonical URL: https://sourcier.uk/blog/reactions-netlify-blobs-astro
Tags : astro, netlify, engineering, frontend
Description : How to add emoji reactions…
Mermaid : 1 converted (image mode)
Code fences : 3 normalised
Series notes : 0 normalised
SVG images : 2 converted to links
Original link: prepended
Body length : 4821 chars
─────────────────────────────────────────
Checking for existing Dev.to article by canonical URL…
No existing article found, will create a new one.
Cross-post to Dev.to? [y/N]

If a matching article already exists (found by canonical_url), the script offers a choice instead: update the existing article, create a new one, or cancel. Pass --update to force update mode, or --update <article-id> to target a specific article without the canonical URL lookup.

How frontmatter is parsed

The script reads collections/posts/<slug>/index.md directly, no Astro build needed. Frontmatter is parsed with a lightweight regex approach rather than a full YAML library, which keeps the script dependency-free.

Tags are normalised for Dev.to’s format: lowercase, alphanumeric only, maximum 4. The # prefix Dev.to adds to tags in its UI is handled by the API automatically.

function normaliseTags(tags = []) {
return tags
.map((t) => t.toLowerCase().replace(/[^a-z0-9]/g, ""))
.filter(Boolean)
.slice(0, 4);
}

Hyphenated tags like web-performance become webperformance. That’s a Dev.to constraint: their tag system doesn’t support hyphens.

Making image URLs absolute

Markdown posts reference images with paths like /post-images/slug/image.webp or ./image.webp. Dev.to renders these relative to dev.to, not your domain, so they’d break.

The script converts both patterns to absolute URLs before sending to the API:

function makeImagesAbsolute(markdown, slug) {
return markdown
.replace(/\(\/post-images\//g, `(${siteBase}/post-images/`)
.replace(
/\(\.\/([^)]+\.(png|jpg|jpeg|gif|webp|svg))\)/g,
`(${siteBase}/post-images/${slug}/$1)`
);
}

Converting mermaid diagrams to images

Dev.to doesn’t run the client-side mermaid library this blog uses, so a raw ```mermaid fence would just render as an unstyled code block. The script renders the diagram as an image instead, using the free mermaid.ink rendering service: the diagram source is base64url-encoded into an image URL, with a note linking back to the canonical article for the interactive version.

function mermaidFallback(markdown, canonicalUrl) {
const diagram = markdown.trim();
const encoded = toBase64Url(diagram);
const imageUrl = `https://mermaid.ink/img/${encoded}`;
return [
`![Mermaid diagram](${imageUrl})`,
"",
`> Diagram fallback for Dev.to. View the canonical article for the full version: ${canonicalUrl}`,
].join("\n");
}

Set DEVTO_MERMAID_MODE=code in .env to fall back to the raw code fence instead, if you’d rather readers copy the diagram source.

Converting SVG wireframes to PNG

SVG images (used for wireframes and mockups on this blog) aren’t reliably supported by Dev.to’s renderer. The script swaps the .svg extension for .png in the image URL, pointing at the PNG fallback that post-images:copy already generates for every SVG, and adds a note linking back to the original:

function svgFallback(alt, svgUrl, canonicalUrl) {
const pngUrl = swapSvgExtension(svgUrl);
return [
`![${escapeMarkdownLinkText(alt)}](${pngUrl})`,
"",
`> Diagram fallback for Dev.to. View the canonical article for the original SVG: ${canonicalUrl}`,
].join("\n");
}

Normalising series callouts and adding the original-post link

This blog marks series posts with a <div class="series-callout"> element, which Dev.to would strip as unknown HTML. The script converts it into a plain markdown blockquote, and prepends every article body with a link back to the canonical post, since not everyone reads the canonical URL in the article footer:

function buildOriginalPostLine(title, canonicalUrl) {
return `> Original post: [${escapeMarkdownLinkText(title)}](${canonicalUrl})`;
}

Updating an already cross-posted article

Re-running pnpm crosspost:devto and selecting a post that’s already on Dev.to triggers a lookup by canonical_url against your Dev.to articles. If found, you can update it in place rather than accidentally creating a duplicate:

async function findExistingArticleByCanonical(canonicalUrl) {
const target = normaliseUrl(canonicalUrl);
// paginate through /api/articles/me/all and match on canonical_url
}

This means editing a published post on your blog and re-running the script is enough to keep the Dev.to copy in sync, no need to track article IDs manually.

What to check after publishing

Even with these conversions, Dev.to doesn’t render everything your blog does:

  • Code block titles: title="filename.ts" attributes in fenced code blocks don’t render on Dev.to. The code itself is fine.
  • Expressive code features like line highlighting or diff markers may not survive the normal Dev.to markdown renderer.
  • Fullscreen image expand buttons are added by this blog’s layout at runtime, so they won’t appear on Dev.to. That’s expected: readers can still click through to the canonical article.

After publishing, open the article on Dev.to and do a quick visual check, especially for any mermaid diagrams or SVG wireframes.

Full code listing

Working on something similar?

Need help raising the bar?

I help teams improve engineering practice through hands-on delivery, pragmatic reviews, and mentoring. If you want a second pair of eyes or practical support, let's talk.

  • Engineering practice review
  • Hands-on delivery
  • Team mentoring
Get guidance

If this has been useful, you can back the writing with a one-off tip through a secure Stripe checkout.

Comments

Loading comments…

Leave a comment

Free · Practical · One email per post

Get practical engineering notes

One short email when a new article goes live. Useful if you are breaking into tech, growing as an engineer, or improving engineering practice on your team.