DEV Community

PubliFlow
PubliFlow

Posted on

Building a Multi-Platform Content Publishing System with Next.js 15

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     │
                     └─────────────┘
Enter fullscreen mode Exit fullscreen mode

The flow is:

  1. Author writes content in standard Markdown through the editor
  2. Content passes through a transformation pipeline that normalizes it and generates platform-specific variants
  3. Images are uploaded to a CDN and references are rewritten
  4. 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';
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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,
  };
}
Enter fullscreen mode Exit fullscreen mode

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,
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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'}`,
    };
  });
}
Enter fullscreen mode Exit fullscreen mode

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,
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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`,
  };
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

What I Learned Building This

A few non-obvious lessons from building a real multi-platform publishing system:

  1. Content transformation is never "done." Each platform updates its API and content requirements regularly. Build your adapters to be easily modifiable.

  2. 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.

  3. Error handling per platform is critical. One platform being down shouldn't prevent publishing to others. Promise.allSettled is your friend.

  4. 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.

  5. 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.

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.

Top comments (0)