DEV Community

Nicholas Ma
Nicholas Ma

Posted on

How I Built a Markdown Converter with Next.js 16 & Cloudflare Workers

I've always been frustrated by existing Markdown converters. They either slap watermarks on your PDFs, require sign-ups, or charge you for basic features. So I built LoveMarkdown.And it runs entirely on Cloudflare Workers.

Here's the technical breakdown of how it works and the decisions I made along the way.

The Architecture

┌─────────────────────────────────────────────┐
│            Cloudflare Workers                │
│  ┌─────────────────────────────────────┐    │
│  │         Next.js 16 (App Router)      │    │
│  │  ┌──────────┐  ┌──────────────────┐ │    │
│  │  │  SSR     │  │  Client-Side     │ │    │
│  │  │  Pages   │  │  Conversion      │ │    │
│  │  └──────────┘  └──────────────────┘ │    │
│  └─────────────────────────────────────┘    │
└─────────────────────────────────────────────┘
         │                    │
         ▼                    ▼
   Static Assets        Browser APIs
   (highlight.js,      (html2canvas,
    KaTeX CSS)          File API, Blob)
Enter fullscreen mode Exit fullscreen mode

The key insight: all conversion happens client-side. No server uploads, no privacy concerns. The user's Markdown never leaves their browser.

Next.js 16 App Router Setup

This was my first real project with Next.js 16's App Router. The routing is file-based, so each converter gets its own route:

src/app/
├── page.tsx                    # Landing page
├── markdown-to-pdf/page.tsx    # MD → PDF
├── markdown-to-word/page.tsx   # MD → Word
├── markdown-to-html/page.tsx   # MD → HTML
├── markdown-to-latex/page.tsx  # MD → LaTeX
├── pdf-to-markdown/page.tsx    # PDF → MD
├── html-to-markdown/page.tsx   # HTML → MD
├── word-to-markdown/page.tsx   # Word → MD
└── layout.tsx                  # Shared layout
Enter fullscreen mode Exit fullscreen mode

Each route uses a shared MarkdownEditor component that handles the split-pane UI (edit left, preview right) and export buttons.

The Conversion Pipeline

Markdown → HTML

I use marked for parsing with highlight.js for syntax highlighting. The tricky part was math support — KaTeX expressions like $E = mc^2$ would get mangled by the Markdown parser.

My solution: extract math before parsing, restore after.

function extractMath(md: string): { md: string; placeholders: string[] } {
  const placeholders: string[] = [];

  // Extract display math ($$...$$) first
  let processed = md.replace(/\$\$([\s\S]+?)\$\$/g, (_match, math) => {
    const idx = placeholders.length;
    placeholders.push(`display:${encodeURIComponent(math.trim())}`);
    return `%%MATH_${idx}%%`;
  });

  // Extract inline math ($...$)
  processed = processed.replace(
    /(?<!\$)\$(?!\$)(.+?)(?<!\$)\$(?!\$)/g,
    (_match, math) => {
      const idx = placeholders.length;
      placeholders.push(`inline:${encodeURIComponent(math.trim())}`);
      return `%%MATH_${idx}%%`;
    }
  );

  return { md: processed, placeholders };
}
Enter fullscreen mode Exit fullscreen mode

The placeholders survive marked.parse() untouched, then I swap them back with KaTeX-rendered HTML.

Markdown → PDF

This was the hardest part. I went through three approaches:

  1. Server-side PDF generation — required file uploads, privacy issues
  2. Print-to-PDF via window.print() — inconsistent across browsers
  3. html2canvas + jsPDF — the winner

The trick is rendering the HTML into an off-screen <div>, capturing it with html2canvas, then embedding the image into a PDF with html2pdf.js:

const container = document.createElement("div");
container.innerHTML = `<style>${baseStyles}</style>${bodyHtml}`;

// Apply inline styles — html2canvas ignores <style> blocks
applyInlineStyles(container, template);

// Fix code blocks: expand to show ALL content
container.querySelectorAll("pre").forEach((pre) => {
  pre.style.setProperty("overflow", "visible", "important");
  pre.style.setProperty("white-space", "pre", "important");
});

document.body.appendChild(container);

await html2pdf()
  .set({
    margin: [0.75, 0.5, 0.75, 0.5],
    filename,
    html2canvas: { scale: 2, useCORS: true },
    jsPDF: { unit: "in", format: "letter", orientation: "portrait" },
    pagebreak: {
      mode: ["avoid-all", "css", "legacy"],
      avoid: ["pre", "blockquote", "table", "tr", "h1", "h2", "h3"],
    },
  })
  .from(container)
  .save();
Enter fullscreen mode Exit fullscreen mode

The pagebreak.avoid config is critical — without it, code blocks get sliced in half at page boundaries.

Markdown → Word

For Word export, I use the docx library to build a .docx file programmatically. The challenge is mapping Markdown elements to Word's paragraph/heading/run model.

PDF → Markdown

Reverse conversion uses pdfjs-dist to extract text, then applies heuristics to detect headings (all-caps lines under 80 chars become ## headings):

const trimmed = line.trim();
if (trimmed === trimmed.toUpperCase() && trimmed.length < 80 && trimmed.length > 3) {
  return `## ${trimmed}`;
}
Enter fullscreen mode Exit fullscreen mode

It's not perfect — real-world PDFs are messy — but it handles most documents reasonably well.

Template System

I built a template system with 10 hand-tuned document styles (Classic, Minimal, Academic, etc.) and 3 density presets (Compact, Standard, Relaxed). Each template defines colors, fonts, font sizes, and spacing:

interface Template {
  id: string;
  name: string;
  colors: {
    heading: string;
    text: string;
    link: string;
    codeBackground: string;
    // ... 12 more color tokens
  };
  fonts: { heading: string; body: string; code: string };
  fontSizes: { h1: number; h2: number; /* ... */ body: number; code: number };
  spacing: { headingBefore: number; headingAfter: number; /* ... */ };
}
Enter fullscreen mode Exit fullscreen mode

The same template drives the live preview, PDF output, Word output, and Google Docs export — one source of truth for all formats.

Deploying to Cloudflare Workers with OpenNext

This is where things got interesting. Cloudflare Workers don't support Node.js APIs natively, so you can't just next build && next start. You need OpenNext to adapt the build.

The build script handles the chicken-and-egg problem:

// scripts/cf-build.js
if (process.env.OPENNEXT_PARENT === "1") {
  // Inner call: plain Next.js build
  run("npx", ["next", "build"]);
} else {
  // Outer call: OpenNext build
  run("npx", ["opennextjs-cloudflare", "build"], {
    ...process.env,
    OPENNEXT_PARENT: "1",
  });
}
Enter fullscreen mode Exit fullscreen mode

Why the guard? opennextjs-cloudflare build internally re-runs npm run build for the Next.js step. If build pointed at OpenNext itself, you'd get infinite recursion. The OPENNEXT_PARENT env var marks the inner invocation so it only runs next build.

The deploy pipeline is simple:

npm run build          # Produces .open-next/ artifact
npx wrangler deploy    # Push to Cloudflare
Enter fullscreen mode Exit fullscreen mode

Key Libraries

Library Purpose
marked Markdown → HTML parsing
highlight.js Syntax highlighting
katex LaTeX math rendering
html2canvas DOM → canvas capture
html2pdf.js Canvas → PDF generation
docx Word document generation
pdfjs-dist PDF text extraction
mammoth Word → HTML conversion
turndown HTML → Markdown conversion

Lessons Learned

  1. Inline everything for html2canvas. It ignores <style> blocks — you must set styles directly on elements via setAttribute("style", ...).

  2. KaTeX needs pre-processing. Don't let the Markdown parser touch LaTeX syntax. Extract placeholders first, render after.

  3. Page breaks are hard. The pagebreak config in html2pdf.js needs mode: ["avoid-all", "css", "legacy"] to actually work. Without "avoid-all", the avoid list is ignored.

  4. Cloudflare Workers + Next.js is doable but quirky. The OpenNext adapter handles most edge cases, but you need to understand the build pipeline to debug issues.

  5. Client-side conversion = zero server cost. The entire app is static assets served from Cloudflare's edge. No API calls, no server processing.

Try It

If you want to convert Markdown to anything, check out LoveMarkdown. Works with:

Source code is not open-source yet, but I'm considering it. Questions? Drop them in the comments.

Top comments (0)