DEV Community

Cover image for I Built a System That Cross-Posts to 5 Platforms With One Click — Here's How
Sanjay Singh
Sanjay Singh

Posted on Originally published at zyvop.com

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

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

Enter fullscreen mode Exit fullscreen mode

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;
}

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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';
  },
});

Enter fullscreen mode Exit fullscreen mode

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;
}

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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,
    },
  }),
});

Enter fullscreen mode Exit fullscreen mode

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 }
  }
}`;

Enter fullscreen mode Exit fullscreen mode

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 };
}

Enter fullscreen mode Exit fullscreen mode

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(),
});

Enter fullscreen mode Exit fullscreen mode

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}`;

Enter fullscreen mode Exit fullscreen mode

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',
  }),
});

Enter fullscreen mode Exit fullscreen mode

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" }

Enter fullscreen mode Exit fullscreen mode

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);
}

Enter fullscreen mode Exit fullscreen mode

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);
}

Enter fullscreen mode Exit fullscreen mode

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;

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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.


Originally published on ZyVOP

💡 For more articles like this, subscribe to the ZyVOP newsletter!

Top comments (0)