Guide
Generating PDFs from Markdown in Code: Python, Node, Rust, React and Next.js
Published July 30, 2026
Adding Markdown-to-PDF to an application is a different problem from converting a file at your desk. You need it to be reliable under concurrency, fast enough for a request cycle, and safe against hostile input — none of which come up when you run a CLI by hand.
This guide gives working implementations in five stacks, with the production concerns each one raises.
Choosing your approach
Two architectural decisions come first.
Where does rendering happen? Client-side keeps content on the user’s machine, uses no server resources, and scales for free. Server-side gives consistent output regardless of the user’s browser, works without JavaScript, and can be triggered by a webhook or a cron job. If your content is sensitive, client-side is worth choosing for that reason alone.
Which engine? A browser engine (Puppeteer, Playwright) gives full CSS support and can run JavaScript, at the cost of a ~300 MB dependency and 200–500 MB of memory per instance. A dedicated engine (WeasyPrint, printpdf) is lighter and deterministic but cannot run scripts. See the Pandoc comparison for the full engine trade-off.
| Approach | Memory per job | Cold start | CSS | JS content |
|---|---|---|---|---|
| Puppeteer / Playwright | 200–500 MB | 300–800 ms | Full | Yes |
| WeasyPrint | 30–80 MB | 50–150 ms | Paged media, excellent | No |
| ReportLab | 10–30 MB | ~10 ms | None — own API | No |
| printpdf (Rust) | 5–20 MB | ~5 ms | None — own API | No |
| Client-side (browser) | User’s browser | None | Full | Yes |
Python with WeasyPrint
The best server-side option for document work in Python — proper CSS Paged Media, modest resources, deterministic output.
# converter.py
from pathlib import Path
import markdown
from weasyprint import HTML, CSS
from weasyprint.text.fonts import FontConfiguration
class MarkdownConverter:
"""Converts Markdown to PDF. Reuses the font config across calls, which
is the expensive part to construct."""
def __init__(self, stylesheet: Path):
self._font_config = FontConfiguration()
self._css = CSS(filename=str(stylesheet), font_config=self._font_config)
self._md = markdown.Markdown(
extensions=[
"tables",
"fenced_code",
"codehilite",
"footnotes",
"toc",
"attr_list",
"def_list",
],
extension_configs={
"codehilite": {"guess_lang": False, "noclasses": False},
"toc": {"permalink": False},
},
)
def to_pdf(self, source: str, *, title: str = "Document") -> bytes:
# Markdown instances are stateful — reset between documents or
# footnote and TOC state leaks from the previous one.
self._md.reset()
body = self._md.convert(source)
html = f"""<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>{title}</title></head>
<body>{body}</body>
</html>"""
return HTML(string=html).write_pdf(
stylesheets=[self._css],
font_config=self._font_config,
)
if __name__ == "__main__":
converter = MarkdownConverter(Path("print.css"))
pdf = converter.to_pdf(Path("document.md").read_text(encoding="utf-8"))
Path("document.pdf").write_bytes(pdf)
self._md.reset() is the bug people hit: markdown.Markdown accumulates footnote definitions and TOC state across convert() calls, so the second document inherits the first’s footnotes. It is not obvious and produces confusing output.
As a FastAPI endpoint
from fastapi import FastAPI, HTTPException
from fastapi.responses import Response
from pydantic import BaseModel, Field
from pathlib import Path
import anyio
app = FastAPI()
converter = MarkdownConverter(Path("print.css"))
MAX_SOURCE_BYTES = 2 * 1024 * 1024
class ConvertRequest(BaseModel):
source: str = Field(..., max_length=MAX_SOURCE_BYTES)
title: str = Field("Document", max_length=200)
filename: str = Field("document.pdf", pattern=r"^[\w.\- ]+\.pdf$")
@app.post("/convert")
async def convert(req: ConvertRequest) -> Response:
if not req.source.strip():
raise HTTPException(400, "source is empty")
# WeasyPrint is synchronous and CPU-bound. Run it off the event loop
# or it blocks every other request.
try:
with anyio.fail_after(30):
pdf = await anyio.to_thread.run_sync(
lambda: converter.to_pdf(req.source, title=req.title)
)
except TimeoutError:
raise HTTPException(504, "conversion timed out")
return Response(
content=pdf,
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{req.filename}"'},
)
Three production points there. anyio.to_thread.run_sync matters because WeasyPrint is synchronous and CPU-bound — calling it directly in an async handler blocks the event loop and serialises every request. The filename pattern prevents header injection via Content-Disposition. And the timeout bounds pathological input.
ReportLab, for programmatic documents
WeasyPrint suits documents whose structure comes from Markdown. When you are generating a document from data — an invoice from database rows — ReportLab’s programmatic API is often a better fit and dramatically lighter:
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import mm
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle
from reportlab.lib import colors
def build_invoice(path: str, rows: list[tuple[str, int, float]]) -> None:
doc = SimpleDocTemplate(
path, pagesize=A4,
leftMargin=18*mm, rightMargin=18*mm,
topMargin=20*mm, bottomMargin=20*mm,
)
styles = getSampleStyleSheet()
story = [Paragraph("Invoice 2026-0148", styles["Title"]), Spacer(1, 8*mm)]
data = [["Description", "Qty", "Amount"]]
data += [[d, str(q), f"{a:,.2f}"] for d, q, a in rows]
total = sum(a for _, _, a in rows)
data.append(["", "Total", f"{total:,.2f}"])
table = Table(data, colWidths=[100*mm, 25*mm, 35*mm], repeatRows=1)
table.setStyle(TableStyle([
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("LINEBELOW", (0, 0), (-1, 0), 0.75, colors.black),
("ALIGN", (1, 0), (-1, -1), "RIGHT"),
("LINEABOVE", (0, -1), (-1, -1), 0.75, colors.black),
("FONTNAME", (0, -1), (-1, -1), "Helvetica-Bold"),
]))
story.append(table)
doc.build(story)
repeatRows=1 is ReportLab’s equivalent of display: table-header-group — the header repeats when the table spans pages. No CSS, no browser, ~20 MB of memory, and it starts instantly.
Node with Puppeteer
The standard approach when you need full CSS and JavaScript-rendered content.
// converter.js
import puppeteer from "puppeteer";
import MarkdownIt from "markdown-it";
import { readFile } from "node:fs/promises";
const md = new MarkdownIt({ html: false, linkify: true, typographer: true })
.use((await import("markdown-it-footnote")).default)
.use((await import("markdown-it-task-lists")).default);
/**
* Browser instances are expensive (~300ms startup, ~300MB RSS). Launch once
* and reuse; create a fresh *page* per conversion for isolation.
*/
class PdfRenderer {
#browser = null;
#css = null;
async init(cssPath) {
this.#css = await readFile(cssPath, "utf8");
this.#browser = await puppeteer.launch({
headless: true,
args: ["--no-sandbox", "--disable-dev-shm-usage", "--font-render-hinting=none"],
});
}
async render(source, { format = "A4" } = {}) {
const page = await this.#browser.newPage();
try {
const html = `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><style>${this.#css}</style></head>
<body>${md.render(source)}</body></html>`;
await page.setContent(html, { waitUntil: "networkidle0", timeout: 20000 });
await page.evaluateHandle("document.fonts.ready");
return await page.pdf({
format,
printBackground: true,
margin: { top: "22mm", bottom: "22mm", left: "18mm", right: "18mm" },
displayHeaderFooter: true,
footerTemplate: `<div style="font-size:8pt;width:100%;text-align:center;color:#888;">
<span class="pageNumber"></span> / <span class="totalPages"></span></div>`,
headerTemplate: "<div></div>",
});
} finally {
await page.close(); // leaking pages is the usual memory bug
}
}
async close() {
await this.#browser?.close();
}
}
export default PdfRenderer;
Notes on the flags. --disable-dev-shm-usage is essential in Docker, where /dev/shm defaults to 64 MB and Chromium crashes without it. --font-render-hinting=none makes output consistent across host font configurations. --no-sandbox is required in most containers and is a real security reduction — only acceptable if the container itself is the sandbox and you trust the input.
page.close() in a finally block is the fix for the commonest production issue: leaked pages accumulate until the process is OOM-killed.
Concurrency
One browser with many pages is the right model, but unbounded pages will exhaust memory. Bound it:
import { PdfRenderer } from "./converter.js";
class Pool {
#renderer;
#queue = [];
#active = 0;
#max;
constructor(renderer, max = 4) {
this.#renderer = renderer;
this.#max = max;
}
async render(source, opts) {
if (this.#active >= this.#max) {
await new Promise((resolve) => this.#queue.push(resolve));
}
this.#active++;
try {
return await this.#renderer.render(source, opts);
} finally {
this.#active--;
this.#queue.shift()?.();
}
}
}
Four concurrent pages against a single browser is a reasonable starting point for a 2 GB container. Measure rather than guess — page memory depends heavily on document size and images.
Express endpoint
import express from "express";
import PdfRenderer from "./converter.js";
const app = express();
app.use(express.json({ limit: "2mb" }));
const renderer = new PdfRenderer();
await renderer.init("./print.css");
const pool = new Pool(renderer, 4);
app.post("/convert", async (req, res) => {
const { source, format } = req.body ?? {};
if (typeof source !== "string" || !source.trim()) {
return res.status(400).json({ error: "source must be a non-empty string" });
}
try {
const pdf = await pool.render(source, { format });
res.set({
"Content-Type": "application/pdf",
"Content-Disposition": 'attachment; filename="document.pdf"',
"Content-Length": pdf.length,
});
res.end(pdf);
} catch (err) {
console.error("conversion failed", err);
res.status(500).json({ error: "conversion failed" });
}
});
// Chromium does not clean up on SIGTERM by itself.
for (const sig of ["SIGTERM", "SIGINT"]) {
process.on(sig, async () => {
await renderer.close();
process.exit(0);
});
}
The shutdown handler matters in Kubernetes: without it, Chromium processes are orphaned on every pod restart.
Markdown-it is safe by default
Note html: false in the markdown-it options. With html: true, raw HTML in the input passes straight through — including <script>. If your input is user-supplied, either keep html: false or sanitise the output with DOMPurify. Puppeteer will execute injected scripts with whatever access the page has.
Rust
Two approaches with very different characters.
pulldown-cmark plus printpdf
Pure Rust, no browser, tiny footprint — and you build layout yourself:
use printpdf::*;
use pulldown_cmark::{Event, Parser, Tag, TagEnd, HeadingLevel};
use std::io::BufWriter;
use std::fs::File;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let source = std::fs::read_to_string("document.md")?;
let (doc, page, layer) =
PdfDocument::new("Document", Mm(210.0), Mm(297.0), "Layer 1");
let regular = doc.add_builtin_font(BuiltinFont::Helvetica)?;
let bold = doc.add_builtin_font(BuiltinFont::HelveticaBold)?;
let mut current = doc.get_page(page).get_layer(layer);
let mut y = 270.0_f32;
let mut font = ®ular;
let mut size = 11.0_f32;
for event in Parser::new(&source) {
match event {
Event::Start(Tag::Heading { level, .. }) => {
font = &bold;
size = match level {
HeadingLevel::H1 => 20.0,
HeadingLevel::H2 => 15.0,
_ => 12.5,
};
y -= size * 0.6;
}
Event::End(TagEnd::Heading(_)) => {
font = ®ular;
size = 11.0;
}
Event::Text(text) => {
// Naive: no wrapping, no measurement. Real layout needs both.
current.use_text(text.as_ref(), size, Mm(20.0), Mm(y), font);
y -= size * 0.55;
}
Event::End(TagEnd::Paragraph) => y -= 4.0,
_ => {}
}
if y < 25.0 {
let (p, l) = doc.add_page(Mm(210.0), Mm(297.0), "Layer");
current = doc.get_page(p).get_layer(l);
y = 270.0;
}
}
doc.save(&mut BufWriter::new(File::create("document.pdf")?))?;
Ok(())
}
That is deliberately shown as naive, because it illustrates the real cost: you are implementing a layout engine. No line wrapping, no font metrics, no widow control, no table layout. Adding those is weeks of work that CSS gives you for free.
Use this approach when the document structure is fixed and simple — a receipt, a label, a certificate — and you want a 3 MB static binary with no runtime dependencies. Do not use it for arbitrary Markdown.
headless_chrome
For real CSS support in Rust, drive a browser:
use headless_chrome::{Browser, LaunchOptions};
use headless_chrome::types::PrintToPdfOptions;
use pulldown_cmark::{Parser, Options, html};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let source = std::fs::read_to_string("document.md")?;
let mut opts = Options::empty();
opts.insert(Options::ENABLE_TABLES);
opts.insert(Options::ENABLE_FOOTNOTES);
opts.insert(Options::ENABLE_STRIKETHROUGH);
opts.insert(Options::ENABLE_TASKLISTS);
let mut body = String::new();
html::push_html(&mut body, Parser::new_ext(&source, opts));
let css = std::fs::read_to_string("print.css")?;
let html_doc = format!(
"<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\
<style>{css}</style></head><body>{body}</body></html>"
);
let browser = Browser::new(LaunchOptions::default_builder().build()?)?;
let tab = browser.new_tab()?;
// A data URL avoids writing a temp file.
let encoded = urlencoding::encode(&html_doc);
tab.navigate_to(&format!("data:text/html,{encoded}"))?
.wait_until_navigated()?;
let pdf = tab.print_to_pdf(Some(PrintToPdfOptions {
print_background: Some(true),
paper_width: Some(8.27),
paper_height: Some(11.69),
margin_top: Some(0.87),
margin_bottom: Some(0.87),
margin_left: Some(0.71),
margin_right: Some(0.71),
..Default::default()
}))?;
std::fs::write("document.pdf", pdf)?;
Ok(())
}
Note print_to_pdf dimensions are in inches, not millimetres — a common source of wrong page sizes.
This gives you Chromium’s full CSS support with Rust’s concurrency story, which is a good combination for a high-throughput service. You inherit Chromium’s limitations, including no @page margin boxes.
React, client-side
Rendering in the browser means content never leaves the user’s machine and your server does no work.
import { useState, useMemo, useCallback, useRef } from "react";
import MarkdownIt from "markdown-it";
const md = new MarkdownIt({ html: false, linkify: true, typographer: true });
const PRINT_CSS = `
@page { size: A4; margin: 22mm 18mm; }
body { font-family: Georgia, serif; font-size: 11pt; line-height: 1.55; }
h1, h2, h3 { break-after: avoid; page-break-after: avoid; }
pre, table, blockquote { break-inside: avoid; page-break-inside: avoid; }
pre { white-space: pre-wrap; background: #f7f7f9; padding: 8pt;
print-color-adjust: exact; -webkit-print-color-adjust: exact; }
thead { display: table-header-group; }
table { width: 100%; border-collapse: collapse; }
th, td { border: 0.5pt solid #ccc; padding: 4pt 6pt; }
`;
export function MarkdownToPdf() {
const [source, setSource] = useState("# Hello\n\nSome **content**.");
const frameRef = useRef(null);
const rendered = useMemo(() => md.render(source), [source]);
const download = useCallback(() => {
// An offscreen iframe isolates print styles from the host app,
// which otherwise leak in and produce surprising output.
const frame = document.createElement("iframe");
frame.style.cssText = "position:fixed;width:0;height:0;border:0;visibility:hidden";
document.body.appendChild(frame);
const doc = frame.contentDocument;
doc.open();
doc.write(`<!doctype html><html lang="en"><head><meta charset="utf-8">
<style>${PRINT_CSS}</style></head><body>${rendered}</body></html>`);
doc.close();
const cleanup = () => frame.remove();
// Wait for fonts before printing, or metrics are wrong.
const fonts = frame.contentDocument.fonts;
const ready = fonts ? fonts.ready : Promise.resolve();
ready.then(() => {
frame.contentWindow.focus();
frame.contentWindow.print();
// The print dialog is modal and synchronous in most browsers, but
// give it a beat before tearing down the frame.
setTimeout(cleanup, 1000);
});
}, [rendered]);
return (
<div className="editor">
<textarea
value={source}
onChange={(e) => setSource(e.target.value)}
spellCheck={false}
/>
<div
className="preview"
ref={frameRef}
dangerouslySetInnerHTML={{ __html: rendered }}
/>
<button onClick={download}>Download PDF</button>
</div>
);
}
Two things worth understanding. The offscreen iframe is not incidental — printing the main document means your application’s CSS participates, and layout, navigation and app chrome end up in the PDF. The iframe gives a clean document with only your print styles.
dangerouslySetInnerHTML is acceptable here only because html: false prevents raw HTML in the Markdown. If you enable html: true, sanitise with DOMPurify first, or you have an XSS vector.
The limitation of this approach is that it goes through the browser’s print dialog. The user chooses “Save as PDF” and picks the filename; you cannot silently produce a file. For direct generation you need a client-side PDF library (jsPDF, pdf-lib) and to build layout yourself, or a paged-media polyfill like Paged.js to get a real paginated preview.
Next.js, server-side
A route handler that returns a PDF, with the Puppeteer concerns handled.
// app/api/convert/route.ts
import { NextRequest, NextResponse } from "next/server";
import MarkdownIt from "markdown-it";
import puppeteer, { type Browser } from "puppeteer-core";
import chromium from "@sparticuz/chromium";
export const runtime = "nodejs"; // Chromium cannot run on the edge runtime
export const maxDuration = 60;
const md = new MarkdownIt({ html: false, linkify: true });
const MAX_BYTES = 2 * 1024 * 1024;
// Cache the browser across warm invocations. On a serverless platform this
// survives between requests to the same instance and saves ~800ms.
let browserPromise: Promise<Browser> | null = null;
function getBrowser(): Promise<Browser> {
// chromium.executablePath() is async, so the whole launch has to live
// inside an async IIFE — assigning the promise (not the browser) is what
// makes concurrent callers share one launch instead of racing.
browserPromise ??= (async () =>
puppeteer.launch({
args: [...chromium.args, "--disable-dev-shm-usage"],
executablePath: await chromium.executablePath(),
headless: true,
}))();
return browserPromise;
}
export async function POST(req: NextRequest) {
let body: { source?: unknown };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "invalid JSON" }, { status: 400 });
}
const { source } = body;
if (typeof source !== "string" || !source.trim()) {
return NextResponse.json({ error: "source required" }, { status: 400 });
}
if (Buffer.byteLength(source, "utf8") > MAX_BYTES) {
return NextResponse.json({ error: "source too large" }, { status: 413 });
}
const browser = await getBrowser();
const page = await browser.newPage();
try {
const html = `<!doctype html><html lang="en"><head><meta charset="utf-8">
<link rel="stylesheet" href="${process.env.NEXT_PUBLIC_SITE_URL}/print.css">
</head><body>${md.render(source)}</body></html>`;
await page.setContent(html, { waitUntil: "networkidle0", timeout: 20_000 });
await page.evaluateHandle("document.fonts.ready");
const pdf = await page.pdf({
format: "a4",
printBackground: true,
margin: { top: "22mm", bottom: "22mm", left: "18mm", right: "18mm" },
});
return new NextResponse(pdf as unknown as BodyInit, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": 'attachment; filename="document.pdf"',
"Cache-Control": "no-store",
},
});
} catch (err) {
console.error(err);
return NextResponse.json({ error: "conversion failed" }, { status: 500 });
} finally {
await page.close();
}
}
Serverless realities
puppeteer-core plus @sparticuz/chromium, not plain puppeteer. The full package bundles a Chromium far larger than most function size limits; @sparticuz/chromium ships a compressed build designed to fit.
runtime = "nodejs". The edge runtime cannot spawn processes, so Chromium is impossible there.
Cold starts are 2–5 seconds with Chromium. Caching the browser promise helps warm invocations substantially and does nothing for the first.
Memory: allocate at least 1 GB, preferably 2 GB. Chromium OOMs below that on non-trivial documents, and the failure looks like an unexplained timeout.
Consider a queue instead. For anything but small documents, a synchronous PDF endpoint is a poor fit for serverless — long-running, memory-hungry, easy to exhaust. Accept the request, return a job ID, render on a worker, notify when ready. See the automation guide for that pattern.
The lighter alternative
If you do not need JavaScript execution, a WeasyPrint service in a small container is dramatically cheaper than Chromium in a function: 60 MB instead of 400, 100 ms cold start instead of 3 seconds, and no size limits to fight. Call it over HTTP from Next.js.
Production concerns across all stacks
Bound the input. Markdown is cheap to write and expensive to render. A 50 MB file, or a table with 100,000 rows, will exhaust memory. Cap source size, and consider capping rendered page count.
Time out. Pathological CSS or a very long document can hang a render indefinitely. Every example above has a timeout for a reason.
Do not trust html: true with user input. Raw HTML means script execution in a browser-based renderer. Keep it off, or sanitise.
Beware SSRF via remote resources. A user-supplied document containing <img src="http://169.254.169.254/latest/meta-data/"> makes your renderer fetch cloud metadata. If input is untrusted, block private address ranges at the network level, or run the renderer with no network access and inline assets yourself.
Cache by content hash. Rendering is expensive and often repeated. Key on a hash of source plus options and you can skip most work.
Watch for zombie processes. Chromium leaks on unclean shutdown. Handle SIGTERM, close pages in finally, and monitor process count.
Try it yourself
If you want to see the client-side approach working before building it, the editor here does exactly that — markdown-it for parsing, Paged.js for pagination, all in the browser, nothing uploaded. It is a useful reference for the architecture, including the paginated-preview behaviour that a plain window.print() cannot give you.
Related: automation and CI, the Pandoc and engine comparison, and print CSS for the stylesheets these examples reference.
About this guide
Written and maintained by the developer of MarkdownToFile — the browser-based converter this site runs on. Techniques described here are tested against the engines named in the text (Chromium's print pipeline, Paged.js, WeasyPrint), and engine limitations are stated rather than glossed over. Corrections are welcome via the contact page; more about the project on the about page.
Try it yourself — free, no signup
Convert your Markdown to a polished PDF right in your browser.
Open the editor