Blog

Node.js document conversion to Markdown

14 Aug 2026 · 6 min read


If you're building a Node.js application that ingests user-uploaded documents, you've probably stared at a folder full of PDFs, DOCX files, and PPTX decks and wondered: how do I normalise all of this into something my LLM pipeline can actually use?

This post walks through using AnyMD's REST API from Node.js — with fetch, axios, TypeScript types, streaming, and Express middleware patterns for production-scale document-to-Markdown conversion.

Why Markdown from Node.js?

Node.js apps handle file uploads naturally — Multer, Busboy, Express middleware. But converting those uploads into clean, structured text for RAG pipelines or AI training data usually means pulling in heavy native bindings (PyMuPDF, LibreOffice, pandoc) that don't belong in a JS runtime.

AnyMD gives you a stateless HTTP API. One call, one response, clean Markdown. No native dependencies, no server-side format libraries, no subprocess calls to Python in your Node.js app.

Quick start with fetch (no dependencies)

Node.js 18+ ships with fetch globally. The simplest one-shot conversion:

const fs = require("fs");
const { readFile } = require("fs/promises");

async function convertToMarkdown(filePath, apiKey) {
  const form = new FormData();
  form.set("file", new Blob([await readFile(filePath)]), filePath);

  const resp = await fetch("https://anymd.net/api/convert", {
    method: "POST",
    headers: { Authorization: `Bearer ${apiKey}` },
    body: form,
  });

  if (!resp.ok) {
    throw new Error(`AnyMD error ${resp.status}: ${await resp.text()}`);
  }

  return await resp.text();
}

// Usage
const md = await convertToMarkdown("report.docx", "your-api-key");
console.log(md);

That's it. AnyMD auto-detects the format from the filename extension and returns GitHub-Flavored Markdown. Headings, lists, tables, code blocks — everything preserved.

TypeScript with typed responses

For TypeScript projects, add proper return types and error handling:

interface ConversionResult {
  markdown: string;
  format: string;
  pages: number;
  duration: number;
}

interface ConversionError {
  status: number;
  message: string;
}

async function convertToMarkdownTyped(
  filePath: string,
  apiKey: string
): Promise<ConversionResult> {
  const form = new FormData();
  const buffer = await fs.promises.readFile(filePath);
  form.set("file", new Blob([buffer]), filePath);

  const start = performance.now();
  const resp = await fetch("https://anymd.net/api/convert", {
    method: "POST",
    headers: { Authorization: `Bearer ${apiKey}` },
    body: form,
  });
  const duration = performance.now() - start;

  if (!resp.ok) {
    const err: ConversionError = {
      status: resp.status,
      message: await resp.text(),
    };
    throw err;
  }

  return {
    markdown: await resp.text(),
    format: filePath.split(".").pop() || "unknown",
    pages: parseInt(resp.headers.get("X-Pages") || "0", 10),
    duration: Math.round(duration),
  };
}

Express middleware for upload endpoints

Here's how to wire AnyMD into an Express server with Multer for file uploads:

import express from "express";
import multer from "multer";
import fs from "fs/promises";

const app = express();
const upload = multer({ dest: "/tmp/uploads/" });
const ANYMD_API = "https://anymd.net/api/convert";

app.post("/upload", upload.single("file"), async (req, res) => {
  try {
    const apiKey = req.headers["authorization"]?.replace("Bearer ", "");
    if (!apiKey) return res.status(401).json({ error: "Missing API key" });

    const form = new FormData();
    const buffer = await fs.readFile(req.file.path);
    form.set("file", new Blob([buffer]), req.file.originalname);

    const anymdResp = await fetch(ANYMD_API, {
      method: "POST",
      headers: { Authorization: `Bearer ${apiKey}` },
      body: form,
    });

    if (!anymdResp.ok) {
      return res.status(anymdResp.status).json({
        error: await anymdResp.text(),
      });
    }

    const markdown = await anymdResp.text();
    res.json({ markdown, pages: anymdResp.headers.get("X-Pages") });
  } catch (err) {
    res.status(500).json({ error: err.message });
  } finally {
    // Cleanup temp file
    if (req.file) fs.unlink(req.file.path).catch(() => {});
  }
});

app.listen(3000);

Batch processing with axios and retries

When you have hundreds of documents to process, sequential calls are too slow. Here's a concurrent batch processor using axios:

import axios from "axios";
import FormData from "form-data";
import fs from "fs";
import path from "path";
import pLimit from "p-limit";

const API = "https://anymd.net/api/convert";

async function convertOne(filePath, apiKey) {
  const form = new FormData();
  form.append("file", fs.createReadStream(filePath), path.basename(filePath));

  for (let attempt = 0; attempt < 3; attempt++) {
    try {
      const resp = await axios.post(API, form, {
        headers: {
          ...form.getHeaders(),
          Authorization: `Bearer ${apiKey}`,
        },
        maxContentLength: 50 * 1024 * 1024,
        timeout: 30_000,
      });

      const outPath = filePath.replace(path.extname(filePath), ".md");
      fs.writeFileSync(outPath, resp.data);
      return { file: path.basename(filePath), ok: true };
    } catch (err) {
      if (attempt === 2) return { file: path.basename(filePath), ok: false, error: err.message };
      const delay = Math.pow(1.5, attempt) * 1000;
      await new Promise(r => setTimeout(r, delay));
    }
  }
}

async function batchConvert(dir, apiKey, concurrency = 10) {
  const files = fs.readdirSync(dir).filter(f =>
    /\.(docx|pdf|pptx|xlsx|epub|odt|rtf)$/i.test(f)
  );
  const limit = pLimit(concurrency);
  const results = await Promise.all(
    files.map(f => limit(() => convertOne(path.join(dir, f), apiKey)))
  );

  const ok = results.filter(r => r.ok).length;
  console.log(`${ok}/${results.length} converted`);
  return results;
}

await batchConvert("./documents", "your-api-key", 10);

This uses p-limit to cap concurrency, wraps each request in retries with exponential backoff, and uses form-data for streaming file uploads.

Streaming large documents

For documents over 10 MB, use the streaming endpoint to avoid buffering the entire Markdown output in memory:

import axios from "axios";
import fs from "fs";
import { Transform } from "stream";

async function streamConvert(filePath, apiKey, outputPath) {
  const form = new FormData();
  form.append("file", fs.createReadStream(filePath), filePath);

  const resp = await axios.post("https://anymd.net/api/convert?download=1", form, {
    headers: {
      ...form.getHeaders(),
      Authorization: `Bearer ${apiKey}`,
    },
    responseType: "stream",
  });

  const writer = fs.createWriteStream(outputPath);
  resp.data.pipe(writer);

  return new Promise((resolve, reject) => {
    writer.on("finish", resolve);
    writer.on("error", reject);
  });
}

Comparing with local Node.js libraries

There are Node.js libraries for document parsing — mammoth for DOCX, pdf-parse for PDFs, xlsx for spreadsheets — but each covers one format with its own API, quirks, and output quality:

FormatNode.js libraryAnyMD
DOCXmammoth — no PPTX/PDF/XLSX supportOne API, 15+ formats
PDFpdf-parse — loses tables, headingsGFM, preserves structure
PPTXpptx2md — CLI only, no APIREST API, async pipeline
XLSXxlsx — CSV export, no MarkdownTables → GFM tables
EPUBepub2md — unmaintained since 2022Active, versioned API

Each library has a different dependency tree, error model, and output format. With AnyMD, one integration covers all 15+ formats. One endpoint. One auth header. One Markdown return type.

Performance: Node.js talking to Rust

AnyMD runs on a Rust/Axum backend. For Node.js clients, this means the tightest possible round-trip latency for document conversion:

FormatDocument sizeAnyMD (ms)Local library (ms)
DOCX50 KB (10 pages)280620 (mammoth)
PDF120 KB (15 pages)4102,100 (pdf-parse)
PPTX200 KB (20 slides)5201,800 (pptx2md)
XLSX80 KB (5 sheets)340950 (xlsx + manual format)

AnyMD matches or beats local Node.js parsing speed for common document sizes, while delivering consistent GFM output across every format. The major advantage isn't just speed — it's uniformity. Every format returns the same Markdown structure regardless of whether the input was a PDF, a DOCX, or a PPTX.

From Markdown to RAG pipeline

Once your Node.js app has clean Markdown, the next step is chunking and embedding for vector search. AnyMD output is the ideal input for semantic chunking — heading boundaries, list structure, and table headers are all preserved, so your chunks respect document semantics rather than cutting through paragraphs mid-sentence.

For a deeper look at chunking strategies on Markdown output, see our guide on recursive character chunking.

Pricing

AnyMD converts documents via a simple page-based pricing model:

PlanPages/monthMax file sizePrice
Free10010 MB$0
Starter50025 MB$19
Pro5,00050 MB$99
EnterpriseCustomCustomCustom

A typical document converts in under 0.5 seconds. 100 free pages per month — no credit card required.

Get your free API key and start converting documents to Markdown from Node.js today.


← Read more →