ZyVOP Logo
Content That Connects
SeriesAI NewsLeaderboardWrite for Us
ZyVOP Logo
Content That Connects

Empowering developers and creators with cutting-edge insights, comprehensive tutorials, and innovative solutions for the digital future.

Content

  • Categories
  • Tags
  • Badges
  • Leaderboard
  • Write Article
  • Newsletter

Company

  • About Us
  • API Documentation
  • Write for Us
  • Contact

Connect

  • Privacy Policy
  • Terms of Service
  • Cookie Policy
  • DMCA Policy
  • Code of Conduct

© 2026 ZyVOP. Crafted with care for the developer community.

Made with ❤️ by the ZyVOP team
All systems operational
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 SinghSenior Developer
August 12, 2026
5 min read
Series

Building ZyVOP in Public

Part 4 of 4

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

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.

Sanju Singh

Sanju Singh

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

Series

Building ZyVOP in Public

Part 4 of 4

Prev
Next

Comments (0)

Login to post a comment.

Related Posts

Angular 22: The End of Boilerplate and the Consolidation of the Reactive Era

If you have been following the evolution of Google's framework over the last few years, you know it has been undergoing a silent reconstruction — piece by piece...

Read article

Tiled Rasterization for Large DOM Captures

SnapDOM moves cropping before image decode, turning huge DOM captures into bounded canvas tiles without sacrificing resolution or allocating one enormous bitmap.

Read article

Trusted Types Is Baseline: DOM XSS Is Now a Type Error

Firefox 148 shipped Trusted Types in February 2026, making it Baseline. Here's how to turn every dangerous innerHTML assignment in your app into a TypeError you can actually catch.

Read article

The Real Cost of Running a Developer Platform

Most "cost of running my SaaS" posts hide the free tiers and skip the disasters. Here's every line item behind ZyVOP — including the Vercel timeout that was one slow API response away from silently killing cross-posts.

Read article

What the Instagram API doesn't tell you about publishing

The Instagram API answers OK and nothing appears on your profile. Six gotchas that cost me an afternoon each: the two step publish, the URL Meta fetches itself, the video wait, carousel limits, and the token that dies quietly.

Read article