You need to turn a Markdown file into a PDF from a Node.js app. The tutorial path is well-trodden: parse the Markdown, render it to HTML, then run headless Chromium to print a PDF. The best-known packaged version of that pipeline is md-to-pdf, a CLI tool that wraps Marked and Puppeteer. It works beautifully on a MacBook, but the moment you ship it, you inherit Chromium bundling, serverless incompatibilities, and a set of styling pitfalls unique to Markdown — browser-default fonts, table overflow, code-block wrapping, and print-media surprises that make plain .md look like a 1998 web page unless you ship extra stylesheets and embed fonts. There is another way. MarkupGo's PDF API accepts Markdown as a native source type, so the same conversion becomes one method call with no pipeline to maintain. This post walks both paths honestly — what each costs, where each breaks, and which fits your stack.
md-to-pdf is a compact wrapper — roughly 250 lines of JS, 500 lines of TypeScript, and 100 lines of CSS [1] — that converts Markdown to HTML with Marked, highlights code with highlight.js, and prints to PDF via Puppeteer (headless Chromium) [1]. Its latest npm version is 5.2.5, last published about 10 months ago, and 79 other projects in the registry depend on it [1]. The default output is A4 with margins and GitHub-style code highlighting [1]. Front-matter lets you override stylesheets and pdf_options like format, margin, and printBackground [1]. The JS engine in front-matter is disabled by default for security, which is good, but also means dynamic configuration requires extra work [1].
On a laptop with Chrome already installed, this feels trivial. In production, it is not.
Deploy md-to-pdf to Vercel or AWS Lambda and you will likely hit "Could not find Chrome" — Puppeteer needs a Chromium binary that serverless hosts do not provide by default [2]. Even when it runs, you are now bundling, decompressing, and upgrading Chromium on every deploy and cold start. The old workaround package, chrome-aws-lambda, is deprecated in favor of @sparticuz/chromium, which shifts the problem rather than solving it — you still decompress a couple hundred megabytes into /tmp on first launch, and that decompression happens on the cold start your users wait through.
Markdown-to-PDF has styling pitfalls that HTML-to-PDF tutorials rarely mention. Browser-default fonts look terrible unless you pull in a stylesheet like github-markdown.min.css, and those fonts must be referenced and embedded or they fall back on the recipient's machine. The Amazon Linux image underlying Lambda ships with almost no fonts, so unresolvable glyphs render as tofu — hollow rectangles — with emoji and CJK text as the classic casualties. The fix is shipping font files in your deployment package, which consumes more of the 250 MB you're already fighting for.
Wide Markdown tables overflow print width [3]. Long code lines that scroll in a browser become unreadable in a PDF [3]. Screen preview and final PDF diverge because PDF export uses print rules — line lengths, pagination, and typography shift [3]. Output can vary by OS and browser because it relies on the browser print path [3]. One Reddit user found md-to-pdf's API usage awkward, calling the docs not the best for API usage [2].
These issues compound. A document that looks fine in your local preview can break in three different ways once deployed: missing fonts, print-media layout shifts, and serverless environment constraints. Each requires its own debugging cycle, and none of them are about the Markdown content you actually care about.
MarkupGo's /pdf endpoint treats Markdown as a first-class source type: set source.type to "markdown" and pass markdown, padding, css, and dark-theme fields [4]. The Node client exposes this as markupgo.pdf.fromMarkdown(input), returning either JSON task data or a buffer [5]. The MarkdownInput type takes markdown, css, dark, and padding, so you can inject custom styles without maintaining a stylesheet file on disk [5].
Here is what the call looks like in practice:
import MarkupGo from "markupgo-node";
import fs from "fs";
const markupgo = new MarkupGo({
API_KEY: process.env.MARKUPGO_API_KEY,
});
const input = {
markdown: fs.readFileSync("report.md", "utf-8"),
css: "body { font-family: system-ui, sans-serif; max-width: 70ch; }",
dark: false,
padding: 20,
};
const pdfOptions = {
properties: {
printBackground: true,
landscape: false,
singlePage: false,
},
};
markupgo.pdf.fromMarkdown(input, pdfOptions).buffer()
.then((buffer) => {
fs.writeFileSync("report.pdf", Buffer.from(buffer));
});
PDF options on the same call include headers and footers with page-number injection via pageNumber and totalPages classes [4], plus singlePage for automatic height adjustment [4], page size, margins, printBackground, and landscape [4]. No Chromium binary, no font bundles, no print-media debugging — the API handles the rendering environment [6].
The singlePage option deserves special mention for Markdown workflows. Set it to true and the API generates a single-page PDF with automatic height adjustment [4]. This is useful for documents like certificates or one-page summaries where pagination is not just unnecessary but actively unwanted — a common Markdown-to-PDF scenario that md-to-pdf does not handle well.
For multi-page documents, the header and footer system uses complete HTML documents with injected printing values:
<html>
<head>
<style>
body { font-size: 12px; margin: auto 20px; }
</style>
</head>
<body>
<p><span class="pageNumber"></span> of <span class="totalPages"></span></p>
</body>
</html>
The available injection classes are date, title, url, pageNumber, and totalPages — enough for standard document metadata without JavaScript execution in the header/footer context.
md-to-pdf is free to run on a server you control, and self-hosted Puppeteer wins on control, air-gapped data, and high steady volume on dedicated hardware [6]. If your PDFs contain regulated data that cannot leave your infrastructure, self-hosting is not the expensive option — it is the only option. If you run long-running servers with predictable load, the local Chromium instance stays warm, cold starts do not exist, and you avoid per-conversion pricing entirely.
MarkupGo's hosted API wins on serverless, spiky traffic, small teams, and anyone tired of font and styling yak-shaving [6]. The entire failure-mode list from the previous section — Chromium bundling, lockstep upgrades, /tmp decompression, memory kills, font packs, leaked browser processes — stops being your list [6].
Pricing is Lite at $29/month for 2,000 credits, Plus at $49/month for 10,000 credits, Pro at $99/month for 20,000 credits with unlimited templates [7]. At Lite that is roughly 1.5¢ per credit. The free trial grants 100 credits per month with no credit card required — enough to test real Markdown files through the pipeline, not just hello-world samples [6].
The predictability matters for budgeting — no surprise GB-second charges from a Lambda memory spike on a long table.
It is worth understanding where md-to-pdf sits in the larger ecosystem. Nearly all Markdown-to-PDF tools use one of two engines: a Chromium browser engine or a LaTeX engine [8]. The LaTeX path, typified by Pandoc with xelatex or lualatex, produces high-quality paginated output but requires installing a multi-gigabyte TeX distribution and careful font configuration [8]. The Chromium path is what md-to-pdf and most Node-based tools choose, trading installation size for the complexity we have already discussed.
Within the Chromium camp, legacy page-break-before/after CSS properties are honored more reliably by print engines than the modern break-before/after aliases [8]. Most browser-engine Markdown converters do not treat YAML frontmatter as metadata; some render it as a table or print it as literal text [8]. md-to-pdf handles frontmatter for configuration but does not automatically style it as document metadata — another gap between "it renders" and "it looks right."
This ecosystem context explains why MarkupGo's approach of treating Markdown as a native source type, rather than piping through local Chromium, is not merely a convenience but a different architectural layer. The API owns the rendering environment, the font stack, the print-media handling, and the pagination logic. You own the content and the styling intent.
Choose md-to-pdf if you run your own servers, need offline or air-gapped rendering, or want total control over the Chromium version and stylesheet stack — and you have the engineering time to debug print layouts, font embedding, and serverless deployment edge cases. Its popularity exists because this is a legitimate need, not because the users are unaware of alternatives.
Choose MarkupGo if you deploy to serverless, serve spiky traffic, work in a small team without DevOps bandwidth, or simply want Markdown-to-PDF without maintaining a rendering pipeline. The same criteria that make self-hosted Puppeteer attractive — control, predictability, no external dependency — become liabilities when the team maintaining them is already stretched.
A hybrid pattern is worth considering: prototype with md-to-pdf locally, where it is fast and requires no API key, then switch the render call to MarkupGo in production behind a single generatePdf(markdown, options) interface. One function to swap, and you keep local iteration speed without shipping Chromium to Lambda.
Test the API path in minutes: sign up for the free trial, install markupgo-node, and call markupgo.pdf.fromMarkdown() with your own .md file to see if the output matches your needs before committing to either stack [5][6]. Test with a real document — a 40-page report with webfonts, not a hello-world — because that is where the two approaches actually differ.
Why Your Generated PDF Looks Different From the Browser: Print CSS Gotchas
Your invoice looks perfect in Chrome. Bold header, branded background, clean table. You pipe the same HTML through page.pdf() — or your API of choice — and the…
Puppeteer on Serverless Keeps Breaking: When to Use a Screenshot API
It's 2am, PagerDuty fires, and the invoice PDF job on Lambda has died again (TimeoutError: Waiting for navigation ... 30000ms exceeded), on every third…