Building a Multi-Platform Content Publishing System with Next.js 15
If you've ever tried to publish the same article across Dev.to, Medium, and Hashnode, you know it's not as simple as copy-pasting. Each platform has its own API, its own Markdown flavor, its own image handling, and its own SEO metadata requirements. Automating this pipeline is a genuinely interesting engineering problem.
In this post, I'll walk through how to build a multi-platform content publishing system using Next.js 15, covering the key technical challenges and how to solve them. I'll use patterns from PubliFlow — a template I built for content-first SaaS products — as a practical reference.
Let's get into it.
The Architecture Overview
Before writing any code, here's the high-level architecture:
┌─────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ Content │────▶│ Transformation │────▶│ Platform │
│ Editor │ │ Pipeline │ │ Adapters │
│ (Markdown) │ │ (Normalize + │ │ (Dev.to, │
│ │ │ Transform) │ │ Medium, etc.) │
└─────────────┘ └──────────────────┘ └─────────────────┘
│
┌──────┴──────┐
│ Image CDN │
│ Upload & │
│ Rewrite │
└─────────────┘
The flow is:
- Author writes content in standard Markdown through the editor
- Content passes through a transformation pipeline that normalizes it and generates platform-specific variants
- Images are uploaded to a CDN and references are rewritten
- Platform adapters handle the final formatting and API calls for each destination
Step 1: Setting Up the Content Model
The first decision is how to model content in a way that's platform-agnostic but still captures platform-specific needs.
// lib/content/schema.ts
export interface ContentArticle {
id: string;
title: string;
slug: string;
body: string; // Raw Markdown
excerpt?: string;
coverImage?: string;
tags: string[];
status: 'draft' | 'published' | 'scheduled';
seo: SEOConfig;
platformConfig: Map<Platform, PlatformOverrides>;
createdAt: Date;
updatedAt: Date;
}
export interface SEOConfig {
metaDescription: string;
canonicalUrl?: string;
ogImage?: string;
ogTitle?: string;
structuredData?: Record<string, unknown>;
}
export interface PlatformOverrides {
title?: string; // Some platforms benefit from different titles
tags?: string[]; // Tag taxonomies differ per platform
publishedAt?: Date; // Schedule per-platform independently
canonicalUrl?: string; // Cross-platform canonical management
}
export type Platform = 'devto' | 'medium' | 'hashnode';
The key insight here is the PlatformOverrides pattern. You don't want to force identical content across all platforms — Dev.to audiences respond to different titles than Medium audiences. But you do want a single source of truth for the body content.
Step 2: The Markdown Transformation Pipeline
This is the heart of the system. Raw Markdown from your editor needs to be transformed in several ways before it's ready for each platform.
2.1 Markdown Normalization
Different editors produce slightly different Markdown. Some use **bold**, others might produce HTML <strong> tags. Some preserve code block language annotations, others don't.
// lib/content/transformer.ts
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkGfm from 'remark-gfm';
import remarkRehype from 'remark-rehype';
import rehypeStringify from 'rehype-stringify';
import rehypeSanitize from 'rehype-sanitize';
export async function normalizeMarkdown(rawMarkdown: string): Promise<string> {
const result = await unified()
.use(remarkParse)
.use(remarkGfm) // GitHub Flavored Markdown support
.use(remarkRehype)
.use(rehypeSanitize) // Strip potentially dangerous HTML
.use(rehypeStringify)
.process(rawMarkdown);
return String(result);
}
Using the unified/remark/rehype ecosystem gives you a composable transformation pipeline. You can add plugins for syntax highlighting, custom containers, or any platform-specific extensions.
2.2 Image Extraction and CDN Upload
One of the trickiest parts of cross-platform publishing is image handling. Dev.to, Medium, and Hashnode all handle images differently:
- Dev.to: You can reference external image URLs
- Medium: Prefers images uploaded through their API (with size limits)
- Hashnode: Has its own image hosting but also supports external URLs
The safest approach is to upload all images to your own CDN first, then reference the CDN URLs everywhere:
// lib/content/image-pipeline.ts
import { visit } from 'unist-util-visit';
interface ImageResult {
markdown: string;
uploadedUrls: Map<string, string>; // original -> CDN URL
}
export async function processImages(
markdown: string,
uploadFn: (buffer: Buffer, filename: string) => Promise<string>
): Promise<ImageResult> {
const tree = unified().use(remarkParse).parse(markdown);
const uploadedUrls = new Map<string, string>();
// Find all image nodes
const imageNodes: ImageNode[] = [];
visit(tree, 'image', (node) => {
imageNodes.push(node);
});
// Upload each image to CDN in parallel
const uploadPromises = imageNodes.map(async (node) => {
const originalUrl = node.url;
// Skip if already a CDN URL
if (originalUrl.startsWith(process.env.CDN_BASE_URL!)) {
return { original: originalUrl, cdn: originalUrl };
}
const buffer = await fetchImageBuffer(originalUrl);
const filename = generateFilename(originalUrl);
const cdnUrl = await uploadFn(buffer, filename);
uploadedUrls.set(originalUrl, cdnUrl);
return { original: originalUrl, cdn: cdnUrl };
});
await Promise.all(uploadPromises);
// Rewrite image URLs in the Markdown AST
visit(tree, 'image', (node) => {
const cdnUrl = uploadedUrls.get(node.url);
if (cdnUrl) {
node.url = cdnUrl;
}
});
return {
markdown: unified().use(remarkStringify).stringify(tree),
uploadedUrls,
};
}
2.3 Platform-Specific Transformations
Each platform needs its content formatted slightly differently:
// lib/content/platform-transformers.ts
export interface PlatformContent {
body: string;
title: string;
tags: string[];
metadata: Record<string, unknown>;
}
// Dev.to expects frontmatter-style metadata and has a tag limit of 4
export function transformForDevTo(article: ContentArticle): PlatformContent {
const tags = article.tags.slice(0, 4); // Dev.to limit
const body = [
'---',
`title: "${article.title}"`,
`published: ${article.status === 'published'}`,
`cover_image: ${article.coverImage || ''}`,
`description: "${article.seo.metaDescription}"`,
`tags: ${tags.join(', ')}`,
'---',
'',
article.body,
].join('\n');
return { body, title: article.title, tags, metadata: { published: true } };
}
// Medium API expects JSON fields, not frontmatter
export function transformForMedium(article: ContentArticle): PlatformContent {
// Medium requires content in HTML or Markdown
// Canonical URL is critical for Medium to avoid duplicate content penalties
return {
body: article.body,
title: article.title,
tags: article.tags.slice(0, 5), // Medium limit
metadata: {
contentFormat: 'markdown',
canonicalUrl: article.seo.canonicalUrl,
publishStatus: article.status === 'published' ? 'public' : 'draft',
},
};
}
// Hashnode has its own content format
export function transformForHashnode(article: ContentArticle): PlatformContent {
return {
body: article.body,
title: article.title,
tags: article.tags.map(tag => ({
name: tag,
// Hashnode requires tag slugs for existing tags
slug: tag.toLowerCase().replace(/\s+/g, ''),
})),
metadata: {
coverImage: article.coverImage,
// Hashnode-specific: enable cover image stickiness
stickCoverToBottom: false,
// Delivered to Hashnode's newsletter if enabled
enableNewsletter: true,
},
};
}
Step 3: Platform API Adapters
Each platform adapter encapsulates the API communication logic:
// lib/platforms/adapter.ts
export interface PublishingResult {
platform: Platform;
success: boolean;
articleUrl?: string;
articleId?: string;
error?: string;
publishedAt?: Date;
}
export interface PlatformAdapter {
name: Platform;
authenticate(token: string): Promise<boolean>;
publish(content: PlatformContent): Promise<PublishingResult>;
update(articleId: string, content: PlatformContent): Promise<PublishingResult>;
delete(articleId: string): Promise<boolean>;
}
// lib/platforms/devto.ts
export class DevToAdapter implements PlatformAdapter {
name = 'devto' as const;
private apiKey: string;
private baseUrl = 'https://dev.to/api';
constructor(apiKey: string) {
this.apiKey = apiKey;
}
async authenticate(): Promise<boolean> {
const response = await fetch(`${this.baseUrl}/users/me`, {
headers: { 'api-key': this.apiKey },
});
return response.ok;
}
async publish(content: PlatformContent): Promise<PublishingResult> {
const response = await fetch(`${this.baseUrl}/articles`, {
method: 'POST',
headers: {
'api-key': this.apiKey,
'Content-Type': 'application/json',
},
body: JSON.stringify({
article: {
title: content.title,
body_markdown: content.body,
published: content.metadata.published,
tags: content.tags,
},
}),
});
if (!response.ok) {
return {
platform: this.name,
success: false,
error: `Dev.to API error: ${response.status} ${await response.text()}`,
};
}
const data = await response.json();
return {
platform: this.name,
success: true,
articleUrl: data.url,
articleId: String(data.id),
publishedAt: new Date(data.published_at),
};
}
// ... update and delete implementations
}
Step 4: Orchestrating the Publishing Pipeline
Now tie it all together with a pipeline that handles the full flow:
// lib/content/publish-pipeline.ts
export async function executePublishPipeline(
article: ContentArticle,
adapters: PlatformAdapter[],
imageUploadFn: ImageUploadFunction
): Promise<PublishingResult[]> {
// Step 1: Normalize the base Markdown
const normalizedBody = await normalizeMarkdown(article.body);
article.body = normalizedBody;
// Step 2: Process and upload images
const { markdown: processedBody, uploadedUrls } = await processImages(
article.body,
imageUploadFn
);
article.body = processedBody;
if (article.coverImage && uploadedUrls.has(article.coverImage)) {
article.coverImage = uploadedUrls.get(article.coverImage);
}
// Step 3: Transform for each target platform in parallel
const transformedContent = adapters.map(adapter => ({
adapter,
content: transformForPlatform(article, adapter.name),
}));
// Step 4: Publish to all platforms in parallel
// Using allSettled ensures one failure doesn't block others
const results = await Promise.allSettled(
transformedContent.map(({ adapter, content }) =>
adapter.publish(content)
)
);
// Step 5: Aggregate results
return results.map((result, index) => {
if (result.status === 'fulfilled') {
return result.value;
}
return {
platform: adapters[index].name,
success: false,
error: `Pipeline error: ${result.reason?.message || 'Unknown error'}`,
};
});
}
Step 5: SEO Considerations for Multi-Platform Publishing
When the same content lives on multiple platforms, SEO can become a nightmare if you're not careful. Here are the critical things to get right:
Canonical URLs
Always set a canonical URL pointing to your own site. This tells search engines where the original content lives and prevents duplicate content penalties.
// Every platform transform should include canonical URL management
function setCanonicalUrl(content: PlatformContent, originalUrl: string): PlatformContent {
return {
...content,
metadata: {
...content.metadata,
canonicalUrl: originalUrl,
},
};
}
Staggered Publishing
Consider publishing to your own site first, then to platforms 24-48 hours later. This gives search engines time to index your original version before the syndicated copies appear.
// lib/content/scheduler.ts
export function scheduleStaggeredPublish(
article: ContentArticle,
platforms: Platform[]
): Map<Platform, Date> {
const schedule = new Map<Platform, Date>();
const baseTime = new Date();
// Your own site publishes immediately
// Dev.to: +24 hours
// Medium: +48 hours
// Hashnode: +24 hours (different audience from Dev.to)
const delays: Partial<Record<Platform, number>> = {
devto: 24 * 60 * 60 * 1000,
hashnode: 24 * 60 * 60 * 1000,
medium: 48 * 60 * 60 * 1000,
};
platforms.forEach(platform => {
const delay = delays[platform] || 0;
schedule.set(platform, new Date(baseTime.getTime() + delay));
});
return schedule;
}
Open Graph and Social Metadata
Each platform generates its own OG tags, but you should ensure your content includes rich metadata that platforms can use for social sharing previews:
- High-quality cover image (at least 1200x630px for optimal social previews)
- Compelling meta description (150-160 characters)
- Article tags that match the platform's taxonomy
Handling Rate Limits and Errors
Platform APIs have different rate limits:
- Dev.to: 30 requests per 30 seconds
- Medium: Not publicly documented, but aggressive
- Hashnode: Generous but undocumented
Build retry logic with exponential backoff:
async function publishWithRetry(
adapter: PlatformAdapter,
content: PlatformContent,
options: { maxRetries: number; baseDelay: number } = { maxRetries: 3, baseDelay: 1000 }
): Promise<PublishingResult> {
for (let attempt = 0; attempt <= options.maxRetries; attempt++) {
const result = await adapter.publish(content);
if (result.success) return result;
// Don't retry on authentication errors
if (result.error?.includes('401') || result.error?.includes('403')) {
return result;
}
// Exponential backoff
if (attempt < options.maxRetries) {
const delay = options.baseDelay * Math.pow(2, attempt);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
return {
platform: adapter.name,
success: false,
error: `Max retries (${options.maxRetries}) exceeded`,
};
}
Putting It All Together in a Next.js 15 Route
Here's how the entire pipeline looks as a Next.js 15 server action:
// app/dashboard/articles/publish/action.ts
'use server';
export async function publishArticle(articleId: string) {
const article = await getArticle(articleId);
const user = await getCurrentUser();
// Initialize adapters for user's connected platforms
const adapters = getActiveAdapters(user.platformTokens);
// Execute the publishing pipeline
const results = await executePublishPipeline(
article,
adapters,
uploadToCDN // Your CDN upload function
);
// Update article status based on results
await updateArticlePublishStatus(articleId, results);
// Revalidate the dashboard cache
revalidatePath('/dashboard/articles');
return results;
}
What I Learned Building This
A few non-obvious lessons from building a real multi-platform publishing system:
Content transformation is never "done." Each platform updates its API and content requirements regularly. Build your adapters to be easily modifiable.
Image handling is 50% of the complexity. If you underestimate image pipeline requirements, the whole system will suffer. Upload to your own CDN first, always.
Error handling per platform is critical. One platform being down shouldn't prevent publishing to others.
Promise.allSettledis your friend.Test with real API responses. Dev.to and Medium API documentation is decent but incomplete. Build your adapters against the actual API, not just the docs.
SEO requires platform-specific thinking. It's not just about canonical URLs — it's about understanding how each platform handles content discovery.
Want to See a Full Implementation?
If you want to see these patterns implemented in a complete, production-ready codebase, check out PubliFlow — a Next.js 15 SaaS template built for content-first products. It includes the multi-platform publishing pipeline described here, plus authentication, Stripe payments, AI writing assistance, and an affiliate system.
- PubliFlow: https://publiflow.vip/
- Starter: $149 · Pro: $399 · Enterprise: $999 (one-time payment)
Whether you use PubliFlow or build your own, I hope this gives you a solid foundation for thinking about multi-platform content publishing architecture. The patterns here are battle-tested and designed to scale.
Got questions about the implementation? Drop a comment — happy to dive deeper into any part of the pipeline.
My Other Projects
VEIGO — An all-in-one platform for overseas life. Find rental housing, job listings, gig work, and bidding services. Simplify your life abroad with a single platform.
- VEIGO: https://shopveigo.com/ — Your one-stop overseas life platform
- PubliFlow: https://publiflow.vip/ — Multi-platform content publishing SaaS template
Top comments (0)