If you build with Node.js, PDFs have a way of showing up right where you least want them: a user uploads a contract you need to index, a vendor sends a spec you want to feed to an LLM, or a nightly job drops a stack of reports into a bucket. Your app speaks JSON, HTML, and Markdown fluently. PDF is the odd one out. Converting those PDFs into clean Markdown, on the server, as part of your normal request or job flow, is how you close that gap.
This guide walks through the practical options for doing PDF to Markdown conversion in Node.js and JavaScript. We will cover the main libraries, show working code for the cases you actually hit in production, and be honest about where a pure npm approach runs out of road and a hosted API becomes the better call.
The Two Halves of the Problem
It helps to split "PDF to Markdown" into two separate jobs, because almost every Node.js library only does one of them well.
- Extraction: pulling text (and ideally structure like headings, lists, and tables) out of the PDF. This is the hard part. PDFs store glyphs at x and y coordinates, not paragraphs, so there is no built-in notion of "this is a heading" or "this is a table row."
- Serialization: turning that extracted structure into Markdown syntax. This part is easy once you have clean structure. It is mostly string building.
Most npm packages give you raw text or a loose token stream. Getting from there to good Markdown means writing the heuristics yourself, or handing the whole thing to a tool that already does it. Keep that split in mind as we go through the options.
The Main Node.js Libraries
pdf-parse
pdf-parse is the quickest way to get text out of a PDF in Node. It wraps Mozilla's pdfjs and returns a single string of the document text. It is tiny, has no native dependencies, and installs cleanly in any environment including serverless.
import fs from 'node:fs/promises'
import pdfParse from 'pdf-parse'
const buffer = await fs.readFile('report.pdf')
const data = await pdfParse(buffer)
console.log(data.text) // raw text of the whole document
console.log(data.numpages)The catch: data.textis a flat blob. There is no heading detection, no list structure, and tables collapse into runs of spaces. It is perfect when all you need is searchable text or a rough input for an LLM, and a poor fit when you need Markdown that preserves the document's shape.
pdfjs-dist
pdfjs-dist is the lower-level Mozilla library that pdf-parse sits on top of. Going direct gives you per-item positioning data: every text run comes with its coordinates, font size, and transform matrix. That extra detail is exactly what you need to reconstruct structure, at the cost of writing the reconstruction logic yourself.
import fs from 'node:fs/promises'
import * as pdfjs from 'pdfjs-dist/legacy/build/pdf.mjs'
const data = new Uint8Array(await fs.readFile('report.pdf'))
const doc = await pdfjs.getDocument({ data }).promise
let out = ''
for (let n = 1; n <= doc.numPages; n++) {
const page = await doc.getPage(n)
const content = await page.getTextContent()
for (const item of content.items) {
// item.str is the text, item.height hints at font size
out += item.str + (item.hasEOL ? '\n' : ' ')
}
}With the font-size hint in item.height you can label the largest runs as headings, group lines into paragraphs by their y-coordinate, and start emitting real Markdown. It works, but you are now maintaining a mini layout engine. For documents with columns, footnotes, or tables it gets fiddly fast.
@opendocsg/pdf2md
@opendocsg/pdf2md is one of the few npm packages that targets Markdown directly. It uses pdfjs under the hood and applies font-size heuristics to detect headings, then emits Markdown for you.
import fs from 'node:fs/promises'
import pdf2md from '@opendocsg/pdf2md'
const buffer = await fs.readFile('report.pdf')
const markdown = await pdf2md(buffer)
await fs.writeFile('report.md', markdown)For clean, text-first PDFs (articles, memos, simple reports) the output is genuinely usable. It struggles with the same things every heuristic tool struggles with: multi-column layouts, dense tables, and scanned pages that contain no text layer at all.
Pandoc via a child process
Pandoc is not a Node library, but you can call it from Node with child_process. It is excellent at converting between markup formats. The important caveat is that Pandoc does not read PDF as an input format. You would need an intermediate step (for example converting the PDF to HTML or DOCX first), which makes it a weak fit for a direct PDF to Markdown path. Reach for it when Markdown is one hop in a larger format-shuffling pipeline, not as your PDF entry point.
A Working Example: Upload Endpoint to Markdown
Here is a realistic case. You have an Express endpoint that accepts a PDF upload and needs to return Markdown. Using pdf-parse for a quick text-first result:
import express from 'express'
import multer from 'multer'
import pdfParse from 'pdf-parse'
const app = express()
const upload = multer({ storage: multer.memoryStorage() })
app.post('/convert', upload.single('file'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No file' })
try {
const { text } = await pdfParse(req.file.buffer)
// naive Markdown: treat blank-line-separated blocks as paragraphs
const markdown = text
.split(/\n\s*\n/)
.map((block) => block.replace(/\s+/g, ' ').trim())
.filter(Boolean)
.join('\n\n')
res.type('text/markdown').send(markdown)
} catch (err) {
res.status(500).json({ error: 'Conversion failed' })
}
})
app.listen(3000)This is fine for plain documents. But notice what it does not do: it will not recover headings, it will mangle any table into a wall of text, and it does nothing at all for a scanned PDF. Those are not edge cases. In most real inboxes they are the majority of files.
Where the npm-Only Approach Breaks Down
Every pure-JavaScript library shares the same three limits, and they are worth naming clearly so you can decide before you are three days into a custom parser.
- Scanned and image PDFs: if the PDF has no text layer, extraction returns nothing. You need optical character recognition, which no lightweight npm package does well. See our guide on converting scanned and image PDFs with OCR for why this is a separate problem class.
- Tables: table cells are just text at coordinates. Reconstructing rows and columns reliably is genuinely hard, and heuristic tools get it wrong often enough to be untrustworthy for financial or data-heavy documents.
- Complex layouts: multi-column pages, sidebars, headers, and footers all bleed into the extracted text in the wrong order unless you do serious layout analysis.
You can push through some of this with more code (Tesseract for OCR, custom column detection, table heuristics), but you are now maintaining a document-understanding system instead of shipping your product. That trade is only worth it if PDF conversion is your product.
When to Reach for a Hosted API
The alternative is to treat conversion as a service call. You POST the file, you get Markdown back, and the hard parts (OCR, layout analysis, table reconstruction) are handled on the other side. Your Node code stays a thin client.
import fs from 'node:fs/promises'
async function convertToMarkdown(path) {
const file = await fs.readFile(path)
const form = new FormData()
form.append('file', new Blob([file], { type: 'application/pdf' }), 'doc.pdf')
const res = await fetch('https://pdftomd.cloud/api/convert', {
method: 'POST',
headers: { Authorization: 'Bearer YOUR_API_KEY' },
body: form,
})
if (!res.ok) throw new Error('Conversion failed: ' + res.status)
const { markdown } = await res.json()
return markdown
}
const md = await convertToMarkdown('report.pdf')
console.log(md)This is the same shape you would use for any upload API. If you are designing the integration for real traffic (retries, timeouts, large files, background jobs), our PDF to Markdown API integration guide walks through the production concerns in detail.
Choosing Between the Options
| Option | Best for | Handles OCR? | Markdown quality |
|---|---|---|---|
| pdf-parse | Quick text extraction | No | Low (flat text) |
| pdfjs-dist | Custom layout logic | No | Depends on your code |
| @opendocsg/pdf2md | Simple text-first PDFs | No | Medium |
| Hosted API | Scanned, tables, scale | Yes | High |
The honest rule of thumb: if your PDFs are clean, text-based, and simple, an npm library is the right amount of tool and keeps everything in-process. The moment you need OCR, reliable tables, or predictable output across messy real-world files, a hosted converter saves you weeks. If you are weighing specific tools side by side, our comparison of the best PDF to Markdown converters covers the landscape.
Batch Processing a Folder in Node.js
A common job is converting a whole directory of PDFs at once. Here is a pattern that reads a folder, converts each file, and writes the Markdown alongside it, with a small concurrency limit so you do not overwhelm the CPU or a remote API.
import fs from 'node:fs/promises'
import path from 'node:path'
const IN_DIR = './pdfs'
const OUT_DIR = './markdown'
const CONCURRENCY = 4
async function run() {
await fs.mkdir(OUT_DIR, { recursive: true })
const files = (await fs.readdir(IN_DIR)).filter((f) => f.endsWith('.pdf'))
for (let i = 0; i < files.length; i += CONCURRENCY) {
const batch = files.slice(i, i + CONCURRENCY)
await Promise.all(
batch.map(async (name) => {
const md = await convertToMarkdown(path.join(IN_DIR, name))
const out = path.join(OUT_DIR, name.replace(/\.pdf$/, '.md'))
await fs.writeFile(out, md)
console.log('converted', name)
}),
)
}
}
run()Swap convertToMarkdown for whichever backend you chose. The batching logic stays the same whether you call a local library or a remote endpoint. If your files land in different systems (Notion, a vector store, a docs repo), the same output feeds all of them. That flexibility is a big part of why Markdown is worth targeting in the first place, and you can see the full range on our use cases page.
Frequently Asked Questions
What is the best Node.js library to convert PDF to Markdown?
For direct Markdown output with no setup, @opendocsg/pdf2md is the most convenient npm option. For raw text you can post-process yourself, pdf-parse is the simplest. For maximum control over layout, use pdfjs-dist directly. None of them handle scanned documents, so for those you need OCR or a hosted converter.
Can I convert a scanned PDF to Markdown in pure JavaScript?
Not reliably with a standard npm library alone. Scanned PDFs have no text layer, so extraction returns empty output. You would need to add an OCR step (for example Tesseract) or use a service that performs OCR as part of conversion. This is the single most common reason teams move from a local script to an API.
How do I preserve tables when converting PDF to Markdown?
Tables are the hardest part because PDF stores cells as positioned text, not rows and columns. Lightweight libraries generally flatten them. Reconstructing clean Markdown tables takes either significant custom logic or a converter built for the job. If tables matter to your documents, test candidate tools on your real files before committing.
Does PDFtoMD have an API for Node.js?
Yes. You send the PDF to the conversion endpoint and receive Markdown back, which fits naturally into any Node service using fetch and FormData. You can try it on the free tier first: 3 conversions per month, no credit card required, and the output is clean structured Markdown.
Should I run conversion in-process or as a separate service?
For low volume and simple PDFs, in-process with an npm library is fine and keeps deployment simple. For high volume, large files, or CPU-heavy work like OCR, move conversion to a background job or an external API so it does not block your request handlers. The integration guide covers queueing and scaling patterns.
The Takeaway
Converting PDF to Markdown in Node.js is a spectrum, not a single answer. At one end, a few lines of pdf-parse or @opendocsg/pdf2md handle clean text documents without any external dependency. At the other end, scanned files, real tables, and messy layouts demand OCR and layout analysis that no lightweight library delivers, and there a hosted converter is the pragmatic choice.
Start with the simplest option that covers your documents, and know the exact point where you should stop building and start calling a service. If you also work in Python, the same reasoning applies there, which we cover in converting PDF to Markdown in Python. Whatever language you ship in, the goal is the same: clean Markdown your app can actually use, without you becoming an accidental maintainer of a PDF parser.
