
Cross-posting sounds simple until you sit down to build it.
Dev.to has a REST API that expects Markdown. Hashnode has a GraphQL API that requires two separate calls. Medium has a REST API that accepts raw HTML — and silently ignores updates to already-published posts. Bluesky runs on the AT Protocol and won't take your article body at all.
Four platforms. Four APIs. Three different content formats. Four different failure modes.
Here's exactly how ZyVOP's syndication engine handles all of them.
How It Triggers
When a writer hits publish on ZyVOP, the PostsService.save method checks whether the new status is PUBLISHED. If it is, the cross-post runs immediately — inline, as a direct await:
// backend/src/modules/posts/services/posts.service.ts
if (saved.status === PostStatus.PUBLISHED) {
await this.crossPost.crossPost(saved, saved.author);
}
This is a deliberate design decision. There's no queue, no background job, no fire-and-forget. The publish request waits for the cross-posting to complete before responding. The writer gets back a result that reflects what actually happened, not an optimistic "we'll handle it later."
Before touching any platform, CrossPostService checks two things per platform: whether the author has connected their API key for that platform, and whether they've enabled the toggle for that post. If either is missing, that platform is skipped cleanly.
The Four Platform Adapters
Dev.to — REST API
Dev.to's API is the most straightforward. ZyVOP sends a POST to https://dev.to/api/articles to create, and a PUT to https://dev.to/api/articles/:id to update.
The payload includes body_markdown — converted from the HTML stored in our database — and the all-important canonical_url field, which tells Dev.to that ZyVOP is the original source.
// Payload shape for Dev.to
{
article: {
title: post.title,
body_markdown: markdownContent,
canonical_url: canonicalUrl,
tags: post.tags,
}
}
Hashnode — GraphQL (Two Steps)
Hashnode uses GraphQL at https://gql.hashnode.com, and it requires two separate calls.
Step 1: Fire a Me query to get the author's publicationId. You can't publish without it.
Step 2: Fire either a PublishPost or UpdatePost mutation with contentMarkdown and originalArticleURL (Hashnode's name for the canonical URL field).
mutation PublishPost($input: PublishPostInput!) {
publishPost(input: $input) {
post {
id
url
}
}
}
The two-step flow means Hashnode has twice the surface area for failure. If the Me query fails — network issue, expired token — the publish never starts. The error handling covers this case explicitly.
Medium — REST API With a Critical Quirk
Medium uses REST at https://api.medium.com/v1/users/:authorId/posts.
The critical quirk: Medium's API does not support updating published posts. Once an article is live on Medium, the API offers no way to edit it. ZyVOP's Medium adapter handles this explicitly — if post.mediumArticleId already exists, it skips the update entirely and returns success:
// Medium adapter
if (post.mediumArticleId) {
// Medium API doesn't support updating published posts
return { platform: 'medium', success: true, url: existingUrl };
}
The other notable quirk: Medium expects raw HTML, not Markdown. The adapter sends contentFormat: 'html' and passes the HTML content directly from the database. It also appends a small footer automatically:
<hr><p><i>Originally published on <a href="${zyvopUrl}">ZyVOP</a></i></p>
Bluesky — AT Protocol
Bluesky runs on the AT Protocol, handled via the @atproto/api SDK with a BskyAgent. But Bluesky isn't a blogging platform — it's a social feed. It won't take a 2,000-word technical article.
ZyVOP's Bluesky adapter posts a short snippet instead: the post title, the excerpt, and the canonical URL back to ZyVOP. The RichText class from the AT Protocol SDK automatically detects links and mentions in the text and converts them into proper facets before posting:
const rt = new RichText({
text: `${post.title}\n\n${post.excerpt}\n\nRead more: ${canonicalUrl}`
});
await rt.detectFacets(agent);
await agent.post({
text: rt.text,
facets: rt.facets,
});
Like Medium, Bluesky skips updates to posts that have already been sent — to avoid flooding a writer's feed with duplicates.
Canonical URLs: Same Value, Four Different Field Names
All three blogging platforms support canonical URLs natively — Bluesky handles it differently:
Platform | Field Name |
|---|---|
Dev.to |
|
Hashnode |
|
Medium |
|
Bluesky | N/A — appended as text |
ZyVOP resolves the canonical URL once and passes it to all four adapters:
const canonicalUrl = post.canonicalUrl || `${this.parser.getFrontendUrl()}/${post.slug}`;
If the writer has provided a custom canonical URL, that takes priority. Otherwise the ZyVOP post URL is used. Every blogging platform that publishes through ZyVOP tells search engines that ZyVOP is the original source. Bluesky appends the link as readable text for the audience, not for crawlers.
Error Handling: Partial Failures Don't Kill Everything
If Dev.to is down when a writer publishes, Hashnode, Medium, and Bluesky should still work. Each platform adapter wraps its API call in an independent try/catch and returns a CrossPostResult instead of throwing:
export interface CrossPostResult {
platform: string;
success: boolean;
url?: string;
error?: string;
}
The CrossPostService collects all four results and passes them to persistResults. This method sanitizes the error messages — stripping HTML tags, truncating to 300 characters — and saves them into a JSONB column on the Post entity called crossPostErrors.
There are no automatic background retries. Instead, the frontend surfaces these errors to the writer, and a retryCrossPost() method allows them to manually retry any failed platform. The writer stays in control.
The Import Problem: Preventing Reflexive Cross-Posts
The most counterintuitive challenge: what happens when a writer imports an existing post from Dev.to, then publishes it on ZyVOP? Without protection, ZyVOP would cross-post it straight back to Dev.to — creating a duplicate with a conflicting canonical URL.
There is no importedFrom column tracking this. Instead, ZyVOP handles it through three default behaviours in PostsImporterService:
1. Imported posts are created as drafts. Since the publish status is DRAFT, the if (saved.status === PostStatus.PUBLISHED) check never fires. The cross-post orchestration never starts.
2. All cross-post flags default to false. The boolean flags (crossPostToDevTo, crossPostToHashnode, etc.) are all off by default. When the writer eventually publishes the imported draft, they have to explicitly turn on each platform.
3. The original canonical URL is preserved. The importer takes the original article URL and sets it as the canonicalUrl on the ZyVOP post. If the writer does cross-post to a new platform, that platform receives the correct original source URL — not a ZyVOP URL pointing to content that lives elsewhere.
Format Conversion: Different Content for Different Platforms
The frontend editor is Tiptap, and content is stored in the database as HTML. Each platform needs something different.
CrossPostParserService handles the conversion centrally:
Dev.to and Hashnode use htmlToMarkdown(), which is built on the turndown library with custom interceptors. The interceptors handle two common edge cases that generic converters get wrong: HTML tables (converted to proper Markdown tables) and code blocks (preserved with correct fencing and language hints).
Medium gets raw HTML directly from the database — no conversion needed. Medium's API accepts it natively, which is why it's the simplest adapter to maintain.
Bluesky uses plain text — just the title, excerpt, and URL. No Markdown, no HTML.
What This Looks Like in Practice
A writer publishes a post on ZyVOP. Here's what happens in sequence:
PostsService.savedetectsPostStatus.PUBLISHEDCrossPostService.crossPostchecks API keys and post flagsEach enabled platform adapter runs in sequence
Dev.to: HTML → Markdown → REST POST with
canonical_urlHashnode:
Mequery → HTML → Markdown → GraphQL mutation withoriginalArticleURLMedium: Raw HTML → REST POST with
canonicalUrlBluesky: Plain text snippet → AT Protocol post with detected facets
Results collected into
CrossPostResult[]Errors saved to
crossPostErrorsJSONB columnWriter sees which platforms succeeded, which failed, and can retry failed ones manually
From a single publish action, the article reaches four platforms — with the canonical URL pointing back to ZyVOP on every one that supports it.
ZyVOP is open to early writers. Publish your first post here and let the syndication engine handle the rest.
Comments (0)
Login to post a comment.