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 StudiesI Built a System That Cross-Posts to 5 Platforms With One Click — Here's How
Case Studies

I Built a System That Cross-Posts to 5 Platforms With One Click — Here's How

How I used the adapter pattern to publish one blog post to Dev.to, Hashnode, Medium, Bluesky, and WordPress simultaneously

Sanju Singh
Sanju SinghSenior Developer
August 16, 2026
10 min read
Series

Building ZyVOP in Public

Part 7 of 7

Prev
Next
I Built a System That Cross-Posts to 5 Platforms With One Click — Here's How
#cross posting#Dev.to API#Hashnode API#Bluesky AT Protocol#WordPress REST API#Building in Public
👍3

Every time I publish a blog post on ZyVop, I want it to also appear on Dev.to, Hashnode, Medium, Bluesky, and WordPress. Manually? That's 20 minutes of copy-pasting, reformatting, and fixing broken code blocks. Five times. For every post.

So I built a cross-posting engine that does it in one click.

This post walks through the adapter pattern I used, the bizarre API inconsistencies across platforms, the content transformation pipeline, and the error handling strategy that makes the whole thing resilient.


The Problem

Content distribution is one of those problems that sounds simple until you actually do it. Each platform:

  • Has a different API (REST, GraphQL, AT Protocol)

  • Expects a different content format (Markdown, HTML, rich text)

  • Has different tag limits (Dev.to: 4 tags, Hashnode: 5, Medium: 5)

  • Handles authentication differently (API keys, OAuth tokens, app passwords)

  • Some support updating posts, some don't

I needed a design that could handle all of this without turning into spaghetti.


The Adapter Pattern

The core idea is simple: every platform implements the same interface. The orchestrator doesn't care about API details — it just calls publish() on each adapter.

Cross-Post Architecture

flowchart LR
    A[Post Published] --> B[CrossPostService]
    B --> C{Platform Enabled?}
    C -->|Dev.to| D[DevToAdapter]
    C -->|Hashnode| E[HashnodeAdapter]
    C -->|Medium| F[MediumAdapter]
    C -->|Bluesky| G[BlueskyAdapter]
    C -->|WordPress| H[WordpressAdapter]
    D --> I[Persist Results]
    E --> I
    F --> I
    G --> I
    H --> I

Each adapter is a standalone NestJS injectable service. The orchestrator checks which platforms the user enabled, verifies they've configured their API keys, and fires off each adapter:

export interface CrossPostResult {
  platform: string;
  success: boolean;
  url?: string;
  error?: string;
}

This return type is the contract. Every adapter returns the same shape — whether it succeeded, the published URL, or what went wrong.

The orchestrator just loops through:

if (post.crossPostToDevTo && userDevToApiKey) {
  results.push(await this.devTo.publish(post, author, userDevToApiKey));
}

if (post.crossPostToHashnode && userHashnodeApiKey) {
  results.push(await this.hashnode.publish(post, author, userHashnodeApiKey));
}

// ... same pattern for Medium, Bluesky, WordPress

No if-else hell. No platform-specific branching in the orchestrator. Each adapter owns its own complexity.


The Content Transformation Problem

Here's where things get interesting. My blog stores content as HTML (it uses a rich text editor). But Dev.to wants Markdown. Medium wants HTML. Bluesky wants plain text with "facets." WordPress wants HTML.

So I built a CrossPostParserService that handles the conversion.

HTML to Markdown Conversion

The backbone is Turndown — an HTML-to-Markdown library. But the default output is broken for blog posts. Code blocks lose their language annotations, and tables come out as garbage.

I had to add custom rules:

this.turndown = new TurndownService({
  headingStyle: 'atx',       // # H1 instead of H1\n===
  codeBlockStyle: 'fenced',  // ```js instead of indented
  bulletListMarker: '-',
});

// Custom rule: preserve language annotations on code blocks
this.turndown.addRule('fencedCodeBlock', {
  filter: ['pre'],
  replacement: (_content, node) => {
    const code = node.querySelector('code');
    let lang = '';
    if (code) {
      const cls = code.getAttribute('class') || '';
      const match = cls.match(/language-(\S+)/);
      if (match) lang = match[1];
    }
    const text = code ? code.textContent : node.textContent;
    return '\n\n```' + lang + '\n' + text + '\n```\n\n';
  },
});

Without this rule, a TypeScript code block would lose its syntax highlighting on Dev.to. That's the difference between a polished cross-post and one that looks like you didn't care.

The HTML Table Problem

HTML tables are even worse. Turndown produces broken Markdown for anything beyond a simple 2-column table. So I wrote a preprocessor that converts tables to Markdown before Turndown touches them:

htmlToMarkdown(html: string, zyvopUrl: string): string {
  // Step 1: Extract tables, convert them ourselves
  const tableMap: string[] = [];
  const preprocessed = html.replace(
    /<table[^>]*>([\s\S]*?)<\/table>/gi,
    (match) => {
      const md = this.convertHtmlTableToMarkdown(match);
      const idx = tableMap.length;
      tableMap.push(md);
      return `<p>ZYVOPTBL${idx}ENDTBL</p>`;  // placeholder
    }
  );

  // Step 2: Let Turndown handle the rest
  let markdown = this.turndown.turndown(preprocessed);

  // Step 3: Swap placeholders back with our Markdown tables
  markdown = markdown.replace(/ZYVOPTBL(\d+)ENDTBL/g, (_, idx) => {
    return tableMap[parseInt(idx, 10)].trim();
  });

  return markdown;
}

The trick: replace HTML tables with placeholder tokens, let Turndown convert everything else, then swap the placeholders with properly formatted Markdown tables. Ugly? Yes. Works perfectly? Also yes.


Platform-by-Platform Breakdown

Publish Flow

sequenceDiagram
    participant App as ZyVop
    participant P as Parser
    participant API as Platform API

    App->>P: Convert HTML content
    P-->>App: Markdown / HTML / Plain text
    App->>API: Check for existing article ID
    alt New post
        App->>API: POST create article
        API-->>App: article ID + URL
        App->>App: Store article ID for future updates
    else Update
        App->>API: PUT update article
        API-->>App: Updated URL
    end

Dev.to — The Straightforward One

Dev.to has the cleanest API. Simple REST, API key in the header, Markdown body. It even supports updating existing articles:

const isUpdate = !!post.devToArticleId;
const url = isUpdate
  ? `https://dev.to/api/articles/${post.devToArticleId}`
  : 'https://dev.to/api/articles';

const res = await fetch(url, {
  method: isUpdate ? 'PUT' : 'POST',
  headers: { 'Content-Type': 'application/json', 'api-key': apiKey },
  body: JSON.stringify({
    article: {
      title: post.title,
      body_markdown: bodyMarkdown,
      published: true,
      series: post.series?.title || undefined,
      canonical_url: canonicalUrl,
      tags,
    },
  }),
});

On first publish, I store devToArticleId so future edits update the same article instead of creating duplicates.

Gotcha: Dev.to limits tags to 4, and they must be lowercase alphanumeric with no spaces. My sanitizeTag() function handles this — stripping special characters and truncating to 30 characters.

Hashnode — The GraphQL One

Hashnode uses GraphQL exclusively. Publishing requires two API calls: one to discover the user's publication ID, then one to create the post.

// Step 1: Find the user's publication
const meQuery = `query Me {
  me {
    id
    publications(first: 1) {
      edges { node { id } }
    }
  }
}`;

// Step 2: Publish to that publication
const publishMutation = `mutation PublishPost($input: PublishPostInput!) {
  publishPost(input: $input) {
    post { id url }
  }
}`;

Unlike Dev.to, Hashnode uses separate mutations for create vs update: PublishPost and UpdatePost. The input shape is nearly identical, but the mutation name differs. I handle this with a simple isUpdate branch rather than trying to abstract the GraphQL layer.

Gotcha: Hashnode's API key goes in a plain Authorization header — no Bearer prefix. Get that wrong and you'll get a cryptic "Invalid API key" error that doesn't tell you what's actually wrong.

Medium — The Stubborn One

Important note: Medium has officially shut down their API for new users. You can no longer generate new integration tokens. However, if you already had an integration token before the shutdown, it still works — which is why this adapter exists. If you're building something similar from scratch today, you'd likely skip Medium or explore unofficial workarounds.

For those with legacy tokens, the API has its own quirks:

  • Uses OAuth-style Bearer tokens

  • Requires fetching the author ID before publishing

  • Does not support updating published posts — at all, via API

That last point is a design decision I had to respect:

if (post.mediumArticleId) {
  this.logger.warn(
    'Medium API does not support updating published posts. Skipping.'
  );
  return { platform: 'medium', success: true };
}

When a user edits a post and republishes, Dev.to and Hashnode get updated. Medium gets skipped. Not ideal, but that's Medium's API limitation, not mine.

Another quirk: Medium accepts HTML directly (no Markdown conversion needed), so it gets the raw content with an "Originally published on ZyVOP" footer appended.

Bluesky — The Protocol One

Bluesky doesn't have a "publish article" API — it's a social network. So cross-posting here means creating a post (like a tweet) with the article title, excerpt, and link.

But Bluesky uses the AT Protocol, which has its own way of handling links. You can't just paste a URL into the text. Links need to be detected as "facets" — structured annotations on the text:

import { BskyAgent, RichText } from '@atproto/api';

const agent = new BskyAgent({ service: 'https://bsky.social' });
await agent.login({ identifier, password: appPassword });

let text = `New post: ${post.title}\n\n`;
if (post.excerpt) text += `${post.excerpt}\n\n`;
text += `Read more: ${canonicalUrl}`;

const rt = new RichText({ text });
await rt.detectFacets(agent);  // Auto-detects links and mentions

await agent.post({
  $type: 'app.bsky.feed.post',
  text: rt.text,
  facets: rt.facets,
  createdAt: new Date().toISOString(),
});

The RichText helper from @atproto/api handles facet detection — it scans the text for URLs and mentions, then creates the structured byte-offset annotations that the AT Protocol requires.

Gotcha: Bluesky doesn't support editing posts via the API. Once posted, it's permanent. I handle this by checking if blueskyPostUrl already exists and skipping the duplicate.

Another gotcha: The API returns an AT URI like at://did:plc:xxx/app.bsky.feed.post/rkey. To construct the web URL, you need to extract the rkey and build it yourself:

const uriParts = postRecord.uri.split('/');
const rkey = uriParts[uriParts.length - 1];
const postUrl = `https://bsky.app/profile/${identifier}/post/${rkey}`;

WordPress — The Classic One

WordPress uses its REST API with Basic Auth (application passwords). It's the most battle-tested API of the bunch, but authentication requires Base64-encoding the credentials:

const credentials = Buffer.from(`${wpUsername}:${wpAppPassword}`)
  .toString('base64');

const res = await fetch(apiUrl, {
  method: isUpdate ? 'PUT' : 'POST',
  headers: {
    'Content-Type': 'application/json',
    Authorization: `Basic ${credentials}`,
  },
  body: JSON.stringify({
    title: post.title,
    content: htmlContent,
    status: 'publish',
  }),
});

WordPress accepts HTML natively, so no Markdown conversion is needed. Like Dev.to, it supports updates via the same endpoint with a PUT method.


Platform Comparison

Here's a summary of how each platform differs:

Feature

Dev.to

Hashnode

Medium

Bluesky

WordPress

API Type

REST

GraphQL

REST

AT Protocol

REST

Content Format

Markdown

Markdown

HTML

Plain text

HTML

Auth Method

API Key

API Key

Bearer Token (legacy)

App Password

Basic Auth

Supports Updates

Yes

Yes

No

No

Yes

API Available

Yes

Yes

Legacy tokens only

Yes

Yes

Tag Limit

4

5

5

N/A

Unlimited

Post Type

Article

Article

Article

Social Post

Article


Error Handling That Doesn't Lose Data

Cross-posting is inherently flaky. APIs go down, rate limits get hit, tokens expire. If Dev.to fails but Hashnode succeeds, I don't want to retry Hashnode. And I definitely don't want to lose track of what failed.

So I persist errors in a JSONB column on the post:

@Column({ type: 'jsonb', nullable: true, default: null })
crossPostErrors?: Record<string, string> | null;
// Example: { "devTo": "API 429: Rate limited", "medium": "Auth failed" }

After each cross-post attempt, the orchestrator merges new results with existing errors:

private async persistResults(postId: string, results: CrossPostResult[]) {
  const errors: Record<string, string> = {};
  const updates: Partial<Post> = {};

  for (const r of results) {
    if (r.success && r.url) {
      // Store the published URL
      if (r.platform === 'dev.to') updates.devToArticleUrl = r.url;
      if (r.platform === 'hashnode') updates.hashnodeArticleUrl = r.url;
      // ... etc
      errors[r.platform] = '';  // Clear previous error
    } else if (r.error) {
      errors[r.platform] = r.error.replace(/<[^>]*>/g, '').slice(0, 300);
    }
  }

  // Merge with existing errors, clear resolved ones
  const existing = existingRow?.crossPostErrors || {};
  const merged = { ...existing, ...errors };
  for (const k of Object.keys(merged)) {
    if (!merged[k]) delete merged[k];
  }

  updates.crossPostErrors = Object.keys(merged).length > 0 ? merged : null;
  await this.repo.update(postId, updates);
}

This gives me:

  • Per-platform error tracking — see exactly which platform failed and why

  • Auto-clearing — when a retry succeeds, the error gets removed

  • Sanitized messages — HTML tags stripped, truncated to 300 chars (no raw stack traces in the DB)


The Retry Mechanism

Sometimes Dev.to is down, or your Hashnode API key rotated. The admin dashboard shows per-platform error messages, and there's a retryCrossPost mutation that lets you retry specific platforms:

async retryCrossPost(
  postId: string,
  userId: string,
  platforms: string[]
): Promise<CrossPostResult[]> {
  const post = await this.repo.findOne({
    where: { id: postId },
    relations: ['author', 'tags'],
  });

  if (post.author.id !== userId && post.author.role !== 'ADMIN') {
    throw new Error('Not authorized');
  }

  if (post.status !== 'PUBLISHED') {
    throw new Error('Can only retry for published posts');
  }

  return this.crossPost(post, post.author, platforms);
}

The platforms parameter lets you retry just the ones that failed. No need to re-publish everywhere.


Canonical URLs — The SEO Angle

One thing most cross-posting guides skip: canonical URLs. When the same article exists on 5 platforms, Google needs to know which one is the original.

Every adapter sets canonical_url (Dev.to), originalArticleURL (Hashnode), or canonicalUrl (Medium) pointing back to the ZyVop URL:

const zyvopUrl = `${this.parser.getFrontendUrl()}/${post.slug}`;
const canonicalUrl = post.canonicalUrl || zyvopUrl;

If the author has set a custom canonical URL (maybe they originally published on their personal blog), that takes priority. Otherwise, ZyVop is the canonical source. This prevents duplicate content penalties in search rankings.


What I'd Do Differently

  1. Queue-based publishing. Currently, cross-posting happens synchronously during the publish flow. If Medium's API takes 10 seconds, the user waits 10 seconds. I should push each platform to a BullMQ job and return immediately.

  2. Webhook-based updates. Right now, editing a post triggers re-publishing to all platforms. A smarter approach would be diffing the content and only re-publishing if something meaningful changed.

  3. Image hosting. Some platforms don't accept external image URLs. Hashnode and Dev.to handle this fine, but Medium can be finicky. A proper solution would upload images to each platform's CDN.


The Module Structure

Here's how the code is organized:

src/modules/cross-post/
├── cross-post.module.ts           # NestJS module definition
├── cross-post.service.ts          # Orchestrator
├── cross-post-parser.service.ts   # HTML→Markdown + table conversion
└── adapters/
    ├── devto.adapter.ts           # Dev.to REST API
    ├── hashnode.adapter.ts        # Hashnode GraphQL API
    ├── medium.adapter.ts          # Medium REST API
    ├── bluesky.adapter.ts         # Bluesky AT Protocol
    └── wordpress.adapter.ts       # WordPress REST API

7 files. ~450 lines total. Each adapter is 70-120 lines. The parser is ~100 lines. The orchestrator is ~140 lines.

Adding a new platform (say, LinkedIn or Ghost) means creating one new adapter file, registering it in the module, and adding the toggle flags to the Post entity. The orchestrator doesn't change.


Wrapping Up

Cross-posting isn't glamorous infrastructure, but it compounds. Every post automatically reaching 5 audiences means 5x the surface area for discovery — with zero extra effort after the initial setup.

The adapter pattern keeps each platform's quirks contained. The error persistence makes failures visible instead of silent. And the canonical URL strategy keeps SEO clean.

If you're building a blogging platform (or even just a personal blog), this is one of those features that pays for itself on day one.


This is part of my Building in Public series, where I share the actual architecture behind ZyVOP — the blogging platform I'm building for developers. Follow along for more deep dives into the engineering decisions behind the product.


ZyVOP is a developer publishing platform where every post you write natively cross-posts to Dev.to, Hashnode, Medium, and Bluesky — with the canonical URL pointing back to your ZyVOP post. Publish your first post here.

Sanju Singh

Sanju Singh

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

Series

Building ZyVOP in Public

Part 7 of 7

Prev
Next

Comments (0)

Login to post a comment.

Related Posts

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

Cross-posting sounds simple until you try to build it. Every platform has different APIs, different formats, different quirks, and different failure modes. Here's the complete technical breakdown of how ZyVOP's syndication engine works under the hood.

Read article