ZYVOPMulti-Platform Sync
SeriesAI NewsWhy ZyVOPJoin Discord
LoginGet Started
ZYVOP
The Developer Publishing Hub
SeriesAI NewsPreview My BlogPrivacyTermsGuidelinesDMCACommunity
© 2026 ZyVOP
HomeCase StudiesHow ZyVOP's Syndication Engine Works: Dev.to, Hashnode, Medium, and Bluesky
Case Studies

How ZyVOP's Syndication Engine Works: Dev.to, Hashnode, Medium, and Bluesky

Dev.to wants Markdown. Medium wants raw HTML. Bluesky won't take your article at all. Here's how ZyVOP handles all four.

Sanju Singh
Sanju Singh
August 12, 2026•
5 min read
Series

Building ZyVOP in Public

Part 4 of 7

PrevNext
How ZyVOP's Syndication Engine Works: Dev.to, Hashnode, Medium, and Bluesky
#buildinpublic#webdev#JavaScript#TypeScript#DevOps#cross posting

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

canonical_url

Hashnode

originalArticleURL

Medium

canonicalUrl

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:

  1. PostsService.save detects PostStatus.PUBLISHED

  2. CrossPostService.crossPost checks API keys and post flags

  3. Each enabled platform adapter runs in sequence

  4. Dev.to: HTML → Markdown → REST POST with canonical_url

  5. Hashnode: Me query → HTML → Markdown → GraphQL mutation with originalArticleURL

  6. Medium: Raw HTML → REST POST with canonicalUrl

  7. Bluesky: Plain text snippet → AT Protocol post with detected facets

  8. Results collected into CrossPostResult[]

  9. Errors saved to crossPostErrors JSONB column

  10. Writer 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.

Series

Building ZyVOP in Public

Part 4 of 7

PrevNext

Comments (0)

Join the discussion by logging into your account.

No comments yet. Be the first to comment!

Sanju Singh
Sanju Singh

Passionate developer sharing knowledge about modern web technologies and best practices.

Subscribe to Sanju Singh's Newsletter

Direct email dispatches when new stories are published. Zero algorithms.

Sanju Singh
Like
Love
Clap
Fire
Party
Wow

More from Sanju Singh

View profile

Architecture Case Study: Migrating a Developer SaaS from Serverless to a $10 VPS with Docker

Serverless platforms like Vercel and AWS Lambda are the default choice for modern web applications.

8 minSep 26

Should You Still Learn to Code Now That AI Can Write It?

Jensen Huang says AI ended the need to learn to code. But Anthropic's randomized trial, METR's productivity study, and Stanford's labor data point somewhere else, toward who really benefits from AI and who just thinks they do.

4 minSep 25

Claude Opus 5.5 Just Landed

Anthropic's Claude Opus 5.5 launched Sept 22, 2026, matching Fable 5.1 on most benchmarks while running 40% cheaper. It adds new Life Sciences and Cyber Verification Programs, lower token pricing, and its best-yet alignment scores.

5 minSep 22

Qwen-Image-2.1: Compact, Efficient, and Unified Image Creation

Alibaba open-sourced Qwen-Image-2.1, a 7B-parameter model unifying generation and editing with native transparency and up to 10 reference images. Day-zero framework support is strong, but it ships under a non-commercial license with no independent benchmarks yet.

5 minSep 21

Cloudflare Quick Tunnels: One Command, Three Hard Limits

Quick Tunnels expose localhost in one command, no signup required. But they cap at 200 concurrent requests, drop Server-Sent Events, and carry no SLA. Here's the mechanics, a Node helper that reads the tunnel URL properly, and when to stop using them.

13 minSep 19