{"schemaVersion":"1.0","type":"Article","types":["Article"],"slug":"i-built-a-system-that-cross-posts-to-5-platforms-with-one-click-here-s-how-ipszn","url":"https://zyvop.com/i-built-a-system-that-cross-posts-to-5-platforms-with-one-click-here-s-how-ipszn","title":"I Built a System That Cross-Posts to 5 Platforms With One Click — Here's How","subtitle":"How I used the adapter pattern to publish one blog post to Dev.to, Hashnode, Medium, Bluesky, and WordPress simultaneously","tldr":"built a cross-posting engine that publishes to 5 platforms with a single click. This post covers the adapter pattern architecture, HTML-to-Markdown conversion quirks, platform API inconsistencies (GraphQL, REST, AT Protocol), JSONB error persistence, retry mechanisms, and canonical URL strategy for SEO.","keywords":["Hashnode API","Dev.to API","Bluesky AT Protocol","Building in Public","WordPress REST API","cross posting","Case Studies","Building ZyVOP in Public"],"entities":["Sanju Singh","Hashnode API","Dev.to API","Bluesky AT Protocol","Building in Public","WordPress REST API","cross posting","Case Studies","Building ZyVOP in Public","ZyVOP"],"keyTakeaways":["Every time I publish a blog post on ZyVop, I want it to also appear on Dev.to, Hashnode, Medium, Bluesky, and WordPress.","That's 20 minutes of copy-pasting, reformatting, and fixing broken code blocks.","So I built a cross-posting engine that does it in one click."],"headings":["The Problem","The Adapter Pattern","Cross-Post Architecture","The Content Transformation Problem","HTML to Markdown Conversion","The HTML Table Problem","Platform-by-Platform Breakdown","Publish Flow","Dev.to — The Straightforward One","Hashnode — The GraphQL One","Medium — The Stubborn One","Bluesky — The Protocol One","WordPress — The Classic One","Platform Comparison","Error Handling That Doesn't Lose Data","The Retry Mechanism","Canonical URLs — The SEO Angle","What I'd Do Differently","The Module Structure","Wrapping Up"],"outboundLinks":["https://github.com/mixmarkdown/turndown","http://dev.to/"],"contentText":"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] --&gt; B[CrossPostService] B --&gt; C{Platform Enabled?} C --&gt;|Dev.to| D[DevToAdapter] C --&gt;|Hashnode| E[HashnodeAdapter] C --&gt;|Medium| F[MediumAdapter] C --&gt;|Bluesky| G[BlueskyAdapter] C --&gt;|WordPress| H[WordpressAdapter] D --&gt; I[Persist Results] E --&gt; I F --&gt; I G --&gt; I H --&gt; 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 &amp;&amp; userDevToApiKey) { results.push(await this.devTo.publish(post, author, userDevToApiKey)); } if (post.crossPostToHashnode &amp;&amp; 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) =&gt; { 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( /&lt;table[^&gt;]*&gt;([\\s\\S]*?)&lt;\\/table&gt;/gi, (match) =&gt; { const md = this.convertHtmlTableToMarkdown(match); const idx = tableMap.length; tableMap.push(md); return `&lt;p&gt;ZYVOPTBL${idx}ENDTBL&lt;/p&gt;`; // 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) =&gt; { 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-&gt;&gt;P: Convert HTML content P--&gt;&gt;App: Markdown / HTML / Plain text App-&gt;&gt;API: Check for existing article ID alt New post App-&gt;&gt;API: POST create article API--&gt;&gt;App: article ID + URL App-&gt;&gt;App: Store article ID for future updates else Update App-&gt;&gt;API: PUT update article API--&gt;&gt;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&lt;string, string&gt; | 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&lt;string, string&gt; = {}; const updates: Partial&lt;Post&gt; = {}; for (const r of results) { if (r.success &amp;&amp; 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(/&lt;[^&gt;]*&gt;/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 &gt; 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&lt;CrossPostResult[]&gt; { const post = await this.repo.findOne({ where: { id: postId }, relations: ['author', 'tags'], }); if (post.author.id !== userId &amp;&amp; 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 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. 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. 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.","contentHash":"sha256:25f3666b3c0c73bc34c32e0457c992a49466d54341cc6f2e91aa971af8e6f9ee","authorName":"Sanju Singh","authorUrl":"https://zyvop.com/author/sanjay687","authorSameAs":[],"category":"Case Studies","tags":["Hashnode API","Dev.to API","Bluesky AT Protocol","Building in Public","WordPress REST API","cross posting"],"audience":"Software engineers and developers building applications with Case Studies","tone":"Practical and evidence-based engineering guidance","readingTimeMinutes":11,"wordCount":2441,"faqs":null,"primaryTopic":"Case Studies","publishedAt":"2026-08-16T04:40:04.427Z","updatedAt":"2026-08-25T10:40:00.305Z","canonicalUrl":"https://zyvop.com/i-built-a-system-that-cross-posts-to-5-platforms-with-one-click-here-s-how-ipszn"}