astro-syndicate: syndicate your blog posts to dev.to automatically
What’s the point of a blog nobody reads? You can push your content to dev.to, Medium, or Hashnode, and people will actually read it there. The catch is that the result isn’t yours. The post lives on someone else’s platform, under their rules. If that platform changes its reach algorithm tomorrow or reworks its business model, the post is still sitting there and you have nothing left in your own hands.
At some point I ran into canonical_url and liked the idea behind it: share your own content across platforms, but tell Google exactly where the original lives. That’s how you build reach on other channels without losing your own domain as the source. The problem is the practice. A post is done, you copy it over to dev.to, re-insert the images or upload them separately, type in the canonical_url by hand, and do all of that again on every edit. That’s exactly the point where writing stops being fun.
So I figured this should just be automated. A few blog posts on this exist already, mostly a script that pushes a post to dev.to once. What I wanted was something that keeps running: notices whether a post actually changed since the last sync, uploads images itself because dev.to has no usable API for that, and doesn’t go live on the first sync. So I spent this afternoon and built astro-syndicate.
What astro-syndicate does
astro-syndicate is an Astro integration that syndicates Markdown and MDX posts to external platforms as part of astro build. dev.to is fully wired up right now; Hashnode and Medium are sketched out in the interfaces but not implemented yet. A post opts in by setting syndicate: true in its frontmatter.
The core is a single function, runSyndication(). It scans src/content/blog/ for files with syndicate: true, parses the frontmatter with gray-matter, resolves local image paths to public URLs through an AssetUploader, and builds a SHA-256 hash from the title, the content, and the image fingerprints. That hash is compared against deployments.contentHash from a previous run:
| State | Action |
|---|---|
No deployments.<provider> yet | Creates a new post |
| Hash changed | Updates the existing post |
| Hash unchanged | Skips it, no API call |
After each run the integration writes the provider id and the new hash back into the frontmatter, so the next build knows what’s already in sync without asking again.
Two images, one upload
dev.to has no public upload endpoint for images, so the integration uploads them itself before a post goes out, through Cloudinary by default. Reference the same image twice in a post and it still only costs one upload; the second occurrence comes out of the uploader’s own cache. Images inside code blocks or inline code are left untouched by the scanner, and it also tells apart src from data-src on lazy-loaded images.
Drafts first
A new post lands on dev.to as a draft (published: false) first. Nothing goes live until you change that yourself, either through DevToProvider’s published: true or DEVTO_PUBLISHED=true. After that, an update to an already-synced post never touches that field again. Publish a post by hand on dev.to and it stays published, even if a later build updates it because the text changed.
canonical_url is metadata for search engines; readers never see it. So by default the integration also appends a short, visible line to the end of every post, “originally published on …”. Set backlink: false in astro.config.mjs if you don’t want that, or pass your own function instead.
And if a sync ever needs undoing, resetContentDir(), or the CLI wrapper npm run reset-content -- src/content/blog, strips the deployments block back out of the frontmatter and prints every dev.to id it touched along the way. It can’t delete anything on dev.to itself, the API has no endpoint for that, only the web dashboard does. But you’ll know exactly what to remove there by hand.
Frontmatter fields
| Field | Required | Notes |
|---|---|---|
syndicate | yes | Must be true, or the file is skipped entirely. |
title | yes | |
slug | no | Defaults to the file name. |
canonicalUrl | no | Defaults to ${siteUrl}/blog/${slug}/. |
coverImage | no | Local path or absolute URL, sent as dev.to’s main_image. |
description | no | Reused as-is, most posts already have one for their own SEO <meta> tag. |
series | no | Groups posts on dev.to, created automatically if it doesn’t exist. |
tags | no | Array or comma-separated string. dev.to allows at most four; extra ones are dropped and logged during the build. |
deployments | managed | Written by the integration, don’t hand-edit it. |
When it actually syncs
astro.config.mjs registers syndication(syndicationOptions), which runs in the astro:build:done hook right after the build. That’s the right fit when your build already happens at or after deploy, since there’s no real gap between “built” and “live”.
If something else runs in between on your host, a separate upload step, CDN propagation, a review stage, then runSyndication() should run instead as its own step after the deploy is confirmed, for example via npm run syndicate in CI. Otherwise dev.to ends up with image and canonical URLs that aren’t live yet at build time. Both paths read the exact same syndicationOptions, so there’s one place to configure either way. And syndicationOptions.enabled makes sure a local build, a PR preview, or a CI run without a deploy never syndicates anything, regardless of which of the two paths is active.
Setup
npm install astro-syndicate
// syndication.config.ts
import { DevToProvider, CloudinaryUploader, type SyndicationOptions } from 'astro-syndicate';
export const syndicationOptions: SyndicationOptions = {
providers: [new DevToProvider({ apiKey: process.env.DEVTO_API_KEY })],
assetUploader: new CloudinaryUploader({
cloudName: process.env.CLOUDINARY_CLOUD_NAME ?? '',
uploadPreset: process.env.CLOUDINARY_UPLOAD_PRESET ?? '',
}),
siteUrl: 'https://example.com',
enabled: process.env.NODE_ENV === 'production',
};
// astro.config.mjs
import { defineConfig } from 'astro/config';
import { syndication } from 'astro-syndicate';
import { syndicationOptions } from './syndication.config';
export default defineConfig({
integrations: [syndication(syndicationOptions)],
});
Run astro build and you’re done. Any post with syndicate: true in its frontmatter goes out on the next build.
A bug only the second test run caught
The integration tests build a real Astro fixture site twice in a row: once from scratch, once with a post edited, fetch mocked so none of it actually reaches dev.to. The second run surfaced a bug the first one never would have: gray-matter caches parsed files globally, keyed on the raw file content. I had mutated the returned data object in place, which quietly corrupted that cache for every other file with the exact same byte content. A second regression test locks in that an update never sends published: false, because dev.to’s API silently reverts an already-published post back to draft when it sees that.
What it doesn’t do
Hashnode and Medium are sketched out as an interface on SyndicationProvider, but there’s no implementation yet. Deleting on dev.to isn’t possible, the API has no endpoint for it. And if you don’t want to host images yourself, you need your own AssetUploader; the interface for that lives in types.ts, and Cloudinary is the only one shipped so far.
Links
- npm:
astro-syndicate - Source: github.com/SlashGordon/astro-syndicate
- A related package for image galleries: astro-gallery