DEV Community

Cover image for Building a Word to PDF Converter in Next.js — Server-Side DOCX Text Extraction with Mammoth and PDF Generation with PDFKit
Shaishav Patel
Shaishav Patel

Posted on

Building a Word to PDF Converter in Next.js — Server-Side DOCX Text Extraction with Mammoth and PDF Generation with PDFKit

Converting a .docx file to PDF server-side comes down to two steps: extract the text content from the DOCX format, then build a new PDF from that text. The Word to PDF converter uses mammoth.js for extraction and PDFKit for generation — both running in a Next.js API route.

Why Server-Side?

DOCX is a ZIP archive containing XML files. Parsing it client-side is possible but adds ~500KB to the bundle (mammoth is not small). Server-side keeps the bundle clean, and the conversion runs in Node.js where Buffer and stream APIs are natively available — exactly what PDFKit needs.

The Route Setup

import mammoth from 'mammoth';
import PDFDocument from 'pdfkit';

export const runtime = 'nodejs'; // PDFKit requires Node.js streams
const MAX_SIZE = 20 * 1024 * 1024;
Enter fullscreen mode Exit fullscreen mode

export const runtime = 'nodejs' is required. PDFKit uses Node.js streams internally — it cannot run in the Edge Runtime.

Both mammoth and pdfkit are declared as serverExternalPackages in next.config.mjs to prevent webpack from bundling them:

serverExternalPackages: ['pdfkit', 'mammoth'],
Enter fullscreen mode Exit fullscreen mode

Both use __dirname internally to locate font and asset files. If webpack bundles them, those path resolutions break.

Receiving the File

const formData = await req.formData();
const file = formData.get('file') as File | null;

const isDocx = file.name.toLowerCase().endsWith('.docx') ||
    DOCX_MIME_TYPES.includes(file.type);
Enter fullscreen mode Exit fullscreen mode

Browsers sometimes send .docx files as application/octet-stream depending on the OS. The validation checks both MIME type and file extension as a fallback.

Step 1: Extract Text with Mammoth

const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);

const mammothResult = await mammoth.extractRawText({ buffer });
const rawText = mammothResult.value ?? '';
Enter fullscreen mode Exit fullscreen mode

mammoth.extractRawText() strips all formatting and returns the plain text content of the document. Mammoth also has convertToHtml() which preserves bold, italic, headings, and lists — but that requires parsing HTML to build the PDF, which adds significant complexity.

For a general-purpose converter, raw text is the right tradeoff: reliable output across all DOCX structures, no edge cases from unusual formatting.

Step 2: Paragraph Detection

DOCX paragraphs are separated by double newlines in mammoth's raw text output:

const paragraphs = rawText
    .split(/\n{2,}/)
    .map(p => p.replace(/\n/g, ' ').trim())
    .filter(Boolean);
Enter fullscreen mode Exit fullscreen mode

The .replace(/\n/g, ' ') collapses single newlines within a paragraph (soft line breaks in Word) into spaces. Double newlines become paragraph breaks. Empty strings are filtered out.

Step 3: Build the PDF with PDFKit

PDFKit uses a streaming API — you write content to a PDFDocument object and collect the output chunks:

const pdfBuffer = await new Promise<Buffer>((resolve, reject) => {
    const doc = new PDFDocument({
        margin: 72,   // 1 inch in PDF points
        size: 'A4',
        info: {
            Title: baseName,
            Author: 'UltimateTools.io',
            Creator: 'UltimateTools.io Word to PDF Converter',
        },
    });

    const chunks: Buffer[] = [];
    doc.on('data', (chunk: Buffer) => chunks.push(chunk));
    doc.on('end', () => resolve(Buffer.concat(chunks)));
    doc.on('error', reject);

    // Title
    doc.fontSize(18)
        .font('Helvetica-Bold')
        .text(baseName, { align: 'center' });
    doc.moveDown(1.5);

    // Body paragraphs
    doc.fontSize(11).font('Helvetica');

    for (const para of paragraphs) {
        doc.text(para, {
            align: 'justify',
            lineGap: 2,
        });
        doc.moveDown(0.75);
    }

    doc.end();
});
Enter fullscreen mode Exit fullscreen mode

Key PDFKit concepts:

Streaming collectiondoc.on('data') fires as PDFKit writes chunks. doc.on('end') fires when done. Buffer.concat(chunks) assembles the final PDF bytes.

Points, not pixels — PDFKit uses PDF points (1/72 of an inch). margin: 72 = 1 inch margin. fontSize(11) = 11pt body text.

Built-in fonts'Helvetica' and 'Helvetica-Bold' are PDF standard fonts, no file loading needed. For custom fonts, PDFKit supports .ttf and .otf via doc.registerFont().

doc.end() — must be called to flush the final chunk and trigger the 'end' event.

Sending the PDF Response

return new NextResponse(new Uint8Array(pdfBuffer), {
    status: 200,
    headers: {
        'Content-Type': 'application/pdf',
        'Content-Disposition': `attachment; filename="${outputName}"`,
        'Content-Length': String(pdfBuffer.byteLength),
    },
});
Enter fullscreen mode Exit fullscreen mode

Content-Disposition: attachment triggers a file download in the browser rather than inline display. The client receives a binary blob.

Client Side: Blob Download

const response = await fetch('/api/convert/word-to-pdf/', {
    method: 'POST',
    body: form, // FormData with the .docx file
});

const blob = await response.blob();
const url = URL.createObjectURL(blob);
setPdfUrl(url);
Enter fullscreen mode Exit fullscreen mode

The anchor tag uses the object URL with a download attribute:

<a href={pdfUrl} download={pdfFileName}>Download PDF</a>
Enter fullscreen mode Exit fullscreen mode

Object URLs are cleaned up on reset via URL.revokeObjectURL(prevUrlRef.current) — a ref tracks the previous URL to prevent memory leaks across multiple conversions.

What Mammoth Doesn't Preserve

extractRawText drops everything except text content: no bold, no italic, no headings hierarchy, no tables, no images, no custom fonts. For a general converter this is fine — you get a clean, readable PDF every time regardless of how the original was styled.

If you need formatting fidelity, mammoth.convertToHtml() preserves semantic structure (headings become <h1>–<h6>, bold becomes <strong>). You'd then parse the HTML and map elements to PDFKit method calls — doable but significantly more complex.

Deployment Note

On Hostinger (and similar Node.js hosts with memory constraints), PDFKit's approach of buffering the entire PDF in memory is fine for typical documents. The risk is very large files with embedded images — but this converter handles text-only content, so memory usage stays low regardless of document length.

Try it: Word to PDF → ultimatetools.io

Top comments (0)