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)
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
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 };
}
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:
- Server-side PDF generation — required file uploads, privacy issues
-
Print-to-PDF via
window.print()— inconsistent across browsers - 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();
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}`;
}
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; /* ... */ };
}
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",
});
}
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
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
Inline everything for html2canvas. It ignores
<style>blocks — you must set styles directly on elements viasetAttribute("style", ...).KaTeX needs pre-processing. Don't let the Markdown parser touch LaTeX syntax. Extract placeholders first, render after.
Page breaks are hard. The
pagebreakconfig in html2pdf.js needsmode: ["avoid-all", "css", "legacy"]to actually work. Without "avoid-all", theavoidlist is ignored.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.
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:
- Markdown to PDF
- Markdown to Word
- Markdown to HTML
- Markdown to LaTeX
- PDF to Markdown
- HTML to Markdown
Source code is not open-source yet, but I'm considering it. Questions? Drop them in the comments.
Top comments (0)