Original post: Automating Dev.to cross-posting from an Astro blog
Series: Part 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
Diagram fallback for Dev.to. View the canonical article for the full version: https://sourcier.uk/blog/crosspost-devto-astro
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:
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
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.
`javascript
function mermaidFallback(markdown, canonicalUrl) {
const diagram = markdown.trim();
const encoded = toBase64Url(diagram);
const imageUrl = https://mermaid.ink/img/${encoded};
return [
,
"",
> 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:
`javascript
function svgFallback(alt, svgUrl, canonicalUrl) {
const pngUrl = swapSvgExtension(svgUrl);
return [
,
"",
> 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:
`javascript
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:
`javascript
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
`js
!/usr/bin/env node
// Cross-posts a blog post to Dev.to with a canonical URL pointing back to sourcier.uk.
// Prepends an original-post link and converts unsupported SVG embeds into PNG fallbacks.
// Reads post content from collections/posts//index.md.
//
// Usage:
// node scripts/crosspost-devto.js
// node scripts/crosspost-devto.js --update
// node scripts/crosspost-devto.js --update
//
// Required environment variables (set in .env or shell):
// DEVTO_API_KEY - API key from https://dev.to/settings/extensions -> DEV Community API Keys
// SITE_URL - public URL of the site, e.g. https://sourcier.uk
import { select, confirm } from "@inquirer/prompts";
import { readFileSync, readdirSync, existsSync } from "fs";
import { join, resolve } from "path";
const root = resolve(new URL(".", import.meta.url).pathname, "..");
const envFile = join(root, ".env");
if (existsSync(envFile) && typeof process.loadEnvFile === "function") {
process.loadEnvFile(envFile);
}
const apiKey = process.env.DEVTO_API_KEY;
const siteBase = (process.env.SITE_URL ?? "https://sourcier.uk").replace(
/\/$/,
"",
);
const mermaidMode = (process.env.DEVTO_MERMAID_MODE ?? "image").toLowerCase();
function parseCliArgs(argv) {
let explicitArticleId = null;
let updateOnly = false;
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === "--help" || arg === "-h") {
console.log("Usage:");
console.log(" node scripts/crosspost-devto.js");
console.log(" node scripts/crosspost-devto.js --update");
console.log(
" node scripts/crosspost-devto.js --update <devto-article-id>",
);
process.exit(0);
}
if (arg === "--update") {
updateOnly = true;
const maybeId = argv[i + 1];
if (maybeId && !maybeId.startsWith("--")) {
explicitArticleId = Number(maybeId);
i += 1;
}
continue;
}
console.error(`Error: Unknown argument '${arg}'.`);
console.error("Use --help to see supported options.");
process.exit(1);
}
return { explicitArticleId, updateOnly };
}
const { explicitArticleId, updateOnly } = parseCliArgs(process.argv.slice(2));
if (!apiKey) {
console.error("Error: DEVTO_API_KEY environment variable is required.");
console.error("Get one at: https://dev.to/settings/extensions");
process.exit(1);
}
if (explicitArticleId !== null && !Number.isInteger(explicitArticleId)) {
console.error("Error: --update must be a valid integer.");
process.exit(1);
}
// -- Frontmatter parser --------------------------------------------------------
function parseFrontmatter(content) {
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (!match) return {};
const yaml = match[1];
const result = {};
for (const line of yaml.split(/\r?\n/)) {
const m = line.match(/^(\w+):\s*["'>]?(.?)["']?\s$/);
if (m) result[m[1]] = m[2].trim();
}
const descBlock = yaml.match(
/^description:\s*>-?\r?\n((?:[ \t]+.+\r?\n?)*)/m,
);
if (descBlock) {
result.description = descBlock[1]
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean)
.join(" ");
}
// Parse tags array: tags: ["tag1", "tag2"]
const tagsMatch = yaml.match(/^tags:\s*[(.+?)]/m);
if (tagsMatch) {
result.tags = tagsMatch[1]
.split(",")
.map((t) => t.trim().replace(/^["']|["']$/g, ""));
}
return result;
}
function stripFrontmatter(content) {
return content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, "").trim();
}
// Dev.to tags: lowercase alphanumeric only, max 4
function normaliseTags(tags = []) {
return tags
.map((t) => t.toLowerCase().replace(/[^a-z0-9]/g, ""))
.filter(Boolean)
.slice(0, 4);
}
function escapeMarkdownLinkText(value) {
return String(value ?? "")
.replace(/\/g, "\\")
.replace(/[/g, "\[")
.replace(/]/g, "\]");
}
function makeUrlAbsolute(url) {
if (!url) return url;
if (/^https?:\/\//i.test(url)) return url;
if (url.startsWith("/")) return ${siteBase}${url};
return url;
}
function buildOriginalPostLine(title, canonicalUrl) {
return > Original post: [${escapeMarkdownLinkText(title)}](${canonicalUrl});
}
function swapSvgExtension(url) {
return url.replace(/.svg(?=(?:\?[^)]*)?$)/i, ".png");
}
// Replace relative /post-images/ paths and ./image references with absolute URLs
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),
);
}
function stripHtmlTags(str) {
let result = str;
let prev;
do {
prev = result;
result = result.replace(/<[^>]*>?/g, "");
} while (result !== prev);
return result;
}
function normaliseSeriesCallout(line) {
const match = line.match(
/^
);
if (!match) return null;
const label = match[1].trim();
const text = stripHtmlTags(
match[2].replace(
/([\s\S]*?)<\/a>/g,
(_, href, linkText) =>
[${escapeMarkdownLinkText(stripHtmlTags(linkText))}](${makeUrlAbsolute(href)}),
),
)
.replace(/\s+/g, " ")
.trim();
return > ${label}: ${text};
}
function toBase64Url(value) {
return Buffer.from(value, "utf8")
.toString("base64")
.replace(/+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "");
}
function normaliseFenceLanguage(info) {
const token = info.trim().split(/\s+/)[0] ?? "";
return /^[a-z0-9#+.-]+$/i.test(token) ? token.toLowerCase() : "";
}
function mermaidFallback(markdown, canonicalUrl) {
const diagram = markdown.trim();
if (!diagram) return "";
if (mermaidMode === "code") {
return [
> Mermaid diagram is available in the canonical article: ${canonicalUrl},
"",
"mermaid",
diagram,
"",
].join("\n");
}
const encoded = toBase64Url(diagram);
const imageUrl = https://mermaid.ink/img/${encoded};
return [
,
"",
> Diagram fallback for Dev.to. View the canonical article for the full version: ${canonicalUrl},
].join("\n");
}
function svgFallback(alt, svgUrl, canonicalUrl) {
const pngUrl = swapSvgExtension(svgUrl);
return [
,
"",
> Diagram fallback for Dev.to. View the canonical article for the original SVG: ${canonicalUrl},
].join("\n");
}
function normaliseSvgImage(line, canonicalUrl) {
const match = line.match(/^![([^]])](([^)\s]+.svg(?:\?[^)])?))$/i);
if (!match) return null;
const alt = match[1].trim() || "Diagram";
const url = match[2];
return svgFallback(alt, url, canonicalUrl);
}
function normaliseMarkdownForDevto(markdown, canonicalUrl, title) {
const lines = markdown.split(/\r?\n/);
const output = [];
const stats = {
mermaidBlocks: 0,
codeFencesNormalised: 0,
seriesCalloutsNormalised: 0,
svgImagesConverted: 0,
};
let inFence = false;
let fenceMarker = "";
let fenceInfo = "";
let fenceLines = [];
for (const line of lines) {
if (!inFence) {
const open = line.match(/^(`{3,})(.*)$/);
if (!open) {
const seriesCallout = normaliseSeriesCallout(line);
if (seriesCallout) {
stats.seriesCalloutsNormalised += 1;
output.push(seriesCallout);
continue;
}
const svgImage = normaliseSvgImage(line, canonicalUrl);
if (svgImage) {
stats.svgImagesConverted += 1;
output.push(svgImage);
continue;
}
output.push(line);
continue;
}
inFence = true;
fenceMarker = open[1];
fenceInfo = open[2] ?? "";
fenceLines = [];
continue;
}
const trimmed = line.trim();
const isFenceClose =
trimmed.startsWith(fenceMarker) &&
/^`+$/.test(trimmed) &&
trimmed.length >= fenceMarker.length;
if (!isFenceClose) {
fenceLines.push(line);
continue;
}
const rawCode = fenceLines.join("\n");
const language = normaliseFenceLanguage(fenceInfo);
if (language === "mermaid") {
stats.mermaidBlocks += 1;
output.push(mermaidFallback(rawCode, canonicalUrl));
} else {
if (fenceInfo.trim()) stats.codeFencesNormalised += 1;
output.push(language ? `\`\`\`${language}` : "
output.push(rawCode);
output.push("```
");
}
inFence = false;
fenceMarker = "";
fenceInfo = "";
fenceLines = [];
}
if (inFence) {
output.push(`${fenceMarker}${fenceInfo}`);
output.push(...fenceLines);
}
return {
markdown: `${buildOriginalPostLine(title, canonicalUrl)}\n\n${output.join("\n").trimStart()}`,
stats,
};
}
function loadPost(slug) {
const filePath = join(root, "collections", "posts", slug, "index.md");
let raw;
try {
raw = readFileSync(filePath, "utf8");
} catch {
throw new Error(`Post not found: collections/posts/${slug}/index.md`);
}
const fm = parseFrontmatter(raw);
if (!fm.title) throw new Error(`No title found in frontmatter for: ${slug}`);
const title = fm.title.replace(/^["']|["']$/g, "");
const canonicalUrl = `${siteBase}/blog/${slug}`;
const markdownBody = makeImagesAbsolute(stripFrontmatter(raw), slug);
const normalised = normaliseMarkdownForDevto(
markdownBody,
canonicalUrl,
title,
);
return {
title,
description: fm.description || "",
tags: normaliseTags(fm.tags),
draft: fm.draft === "true",
pubDate: fm.pubDate ? new Date(fm.pubDate) : null,
body: normalised.markdown,
canonicalUrl,
transformStats: normalised.stats,
};
}
function listPostIds() {
const postsDir = join(root, "collections", "posts");
return readdirSync(postsDir, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => {
try {
const content = readFileSync(
join(postsDir, d.name, "index.md"),
"utf8",
);
const fm = parseFrontmatter(content);
return {
id: d.name,
pubDate: fm.pubDate ? new Date(fm.pubDate) : new Date(0),
isDraft: fm.draft === "true",
isFuture: fm.pubDate ? new Date(fm.pubDate) > new Date() : false,
};
} catch {
return null;
}
})
.filter((p) => p && !p.isDraft && !p.isFuture)
.sort((a, b) => b.pubDate - a.pubDate)
.map((p) => p.id);
}
function normaliseUrl(value) {
return String(value || "").replace(/\/$/, "");
}
function buildArticlePayload(post) {
return {
article: {
title: post.title,
body_markdown: post.body,
published: true,
canonical_url: post.canonicalUrl,
description: post.description,
tags: post.tags,
},
};
}
async function findExistingArticleByCanonical(canonicalUrl) {
const target = normaliseUrl(canonicalUrl);
let page = 1;
const perPage = 100;
while (true) {
const res = await fetch(
`https://dev.to/api/articles/me/all?page=${page}&per_page=${perPage}`,
{
headers: {
"api-key": apiKey,
},
},
);
const data = await res.json();
if (!res.ok) {
throw new Error(
`Unable to list Dev.to articles (${res.status}): ${JSON.stringify(data)}`,
);
}
if (!Array.isArray(data) || data.length === 0) return null;
const existing = data.find((article) => {
const canonical = normaliseUrl(article.canonical_url);
return canonical && canonical === target;
});
if (existing) return existing;
if (data.length < perPage) return null;
page += 1;
}
}
// -- Select post ----------------------------------------------------------------
const postIds = listPostIds();
if (postIds.length === 0) {
console.log("\nNo published posts found.");
process.exit(0);
}
const slug = await select({
message: "Select a post to cross-post:",
choices: postIds.map((id) => ({ value: id })),
}).catch(() => process.exit(0));
let post;
try {
post = loadPost(slug);
} catch (err) {
console.error(`Error: ${err.message}`);
process.exit(1);
}
// -- Preview ---------------------------------------------------------------------
console.log("\n─────────────────────────────────────────");
console.log(` Title : ${post.title}`);
console.log(` Canonical URL: ${post.canonicalUrl}`);
console.log(` Tags : ${post.tags.join(", ") || "(none)"}`);
console.log(
` Description : ${post.description.slice(0, 80)}${post.description.length > 80 ? "…" : ""}`,
);
console.log(
` Mermaid : ${post.transformStats.mermaidBlocks} converted (${mermaidMode} mode)`,
);
console.log(
` Code fences : ${post.transformStats.codeFencesNormalised} normalised`,
);
console.log(
` Series notes : ${post.transformStats.seriesCalloutsNormalised} normalised`,
);
console.log(
` SVG images : ${post.transformStats.svgImagesConverted} converted to links`,
);
console.log(" Original link: prepended");
console.log(` Body length : ${post.body.length} chars`);
console.log("─────────────────────────────────────────\n");
// -- Detect existing Dev.to article -----------------------------------------
let existingArticle = null;
if (explicitArticleId !== null) {
existingArticle = { id: explicitArticleId };
console.log(
`--update ${explicitArticleId} provided - forcing update mode.\n`,
);
} else {
console.log("Checking for existing Dev.to article by canonical URL…");
try {
existingArticle = await findExistingArticleByCanonical(post.canonicalUrl);
} catch (err) {
console.error(`Error: ${err.message}`);
process.exit(1);
}
if (existingArticle) {
console.log(
`Found existing article: https://dev.to/articles/${existingArticle.id}\n`,
);
} else {
console.log("No existing article found - will create a new one.\n");
}
}
const isUpdate = Boolean(existingArticle?.id);
if (updateOnly && !isUpdate) {
console.error(
"Error: --update was set, but no existing Dev.to article was found for this canonical URL.",
);
console.error("Re-run without --update to create a new article instead.");
process.exit(1);
}
// -- Confirm action ------------------------------------------------------------
let proceed;
if (explicitArticleId !== null || updateOnly) {
const ok = await confirm({
message: `Update Dev.to article ${existingArticle.id}?`,
default: false,
}).catch(() => process.exit(0));
if (!ok) {
console.log("Aborted.");
process.exit(0);
}
proceed = "update";
} else if (isUpdate) {
proceed = await select({
message: `An existing article was found. What would you like to do?`,
choices: [
{ name: "Update the existing article", value: "update" },
{ name: "Create a new article", value: "create" },
{ name: "Cancel", value: "cancel" },
],
}).catch(() => process.exit(0));
} else {
const ok = await confirm({
message: "Cross-post to Dev.to?",
default: false,
}).catch(() => process.exit(0));
proceed = ok ? "create" : "cancel";
}
if (proceed === "cancel") {
console.log("Aborted.");
process.exit(0);
}
const doUpdate = proceed === "update";
// -- CREATE or UPDATE via Dev.to API --------------------------------------------
const endpoint = doUpdate
? `https://dev.to/api/articles/${existingArticle.id}`
: "https://dev.to/api/articles";
const method = doUpdate ? "PUT" : "POST";
console.log(`\n${doUpdate ? "Updating" : "Posting"} on Dev.to…`);
const res = await fetch(endpoint, {
method,
headers: {
"Content-Type": "application/json",
"api-key": apiKey,
},
body: JSON.stringify(buildArticlePayload(post)),
});
const data = await res.json();
if (!res.ok) {
console.error(
`\nDev.to API error (${res.status}):`,
JSON.stringify(data, null, 2),
);
process.exit(1);
}
console.log(`\n✓ ${doUpdate ? "Updated" : "Published"}! View at: ${data.url}`);
console.log(" Dev.to article ID:", data.id);
console.log(
"\nNote: Mermaid and expressive code fences were normalised for Dev.to compatibility.",
);
console.log(
"Review the Dev.to article to confirm formatting. Original-post link and canonical URL are set correctly.",
);
Top comments (0)