Logo
Apdf tutorials 6 min read

Convert PowerPoint to PDF in Node.js — Without Office on Your Server

Search npm for “pptx to pdf” and you'll find a handful of wrapper packages — and almost every one of them starts its README with the same requirement: install a full office suite on the machine first, sometimes plus image tools. That's fine on a laptop, and a headache in a Docker image, on a PaaS or in a serverless function.

With Apdf, the conversion happens behind an API. Your Node.js code sends the deck's URL, waits for the job and downloads the PDF — using nothing but the fetch that ships with Node 18 and newer.

What you'll build
A reusable convertToPdf() helper, and an Express route that serves any sales deck as a PDF — converted on the first request, cached after that: GET /decks/kickoff-globex-2026.pdf → 2.2 s on the first request, 2 ms from the cache
Free plan: 1,000 PDF operations a month, no card
1

Convert a deck

The convert endpoint takes the deck's URL and answers right away with a job_id; the conversion runs in the background. The helper polls the job until the PDF is ready. Save it as convert.mjs:

import { writeFile } from 'node:fs/promises';

const API_TOKEN = 'YOUR_API_TOKEN';
const BASE_URL = 'https://apdf.io/api';
const headers = { Authorization: `Bearer ${API_TOKEN}`, Accept: 'application/json' };

async function post(path, body) {
    const response = await fetch(`${BASE_URL}${path}`, { method: 'POST', headers, body: new URLSearchParams(body) });
    if (!response.ok) {
        throw new Error(`${path} failed with ${response.status}: ${await response.text()}`);
    }
    return response.json();
}

// Start the conversion, then poll the job until the PDF is ready.
export async function convertToPdf(documentUrl, timeoutMs = 300_000) {
    const { job_id } = await post('/pdf/file/convert', { file: documentUrl });
    const deadline = Date.now() + timeoutMs;

    while (Date.now() < deadline) {
        const job = await post('/job/status/check', { id: job_id });
        if (job.status === 'successful') return job.result;
        if (job.status === 'failed') throw new Error(`Conversion failed: ${JSON.stringify(job.result)}`);
        await new Promise((resolve) => setTimeout(resolve, 2000));
    }
    throw new Error(`Job ${job_id} did not finish in time`);
}

// Download the finished PDF (the link expires after one hour).
export async function downloadPdf(result) {
    const response = await fetch(result.file);
    if (!response.ok) throw new Error(`Download failed with ${response.status}`);
    return Buffer.from(await response.arrayBuffer());
}

if (import.meta.url === `file://${process.argv[1]}`) {
    const result = await convertToPdf('https://files.northlight.example/decks/proposal-acme-q4-2026.pptx');
    await writeFile('proposal-acme-q4-2026.pdf', await downloadPdf(result));
    console.log(`Saved proposal-acme-q4-2026.pdf: ${result.pages} pages, ${result.size} bytes`);
}

Run node convert.mjs and the five-slide proposal comes back as a five-page PDF, still in its widescreen format. This is the finished job the helper reads:

{
    "id": "01M4D7REN6R9SJA4Z658R0RCDQ",
    "created": "2026-10-08T07:48:31.000000Z",
    "status": "successful",
    "result": {
        "file": "https://apdf-files.s3.eu-central-1.amazonaws.com/5a9656b46ac74acf7ebeb.pdf",
        "size": 42991,
        "pages": 5,
        "expiration": "2026-10-08T08:48:32.440485Z"
    }
}
Tip: The deck has to be reachable by URL. For files in a private bucket, pass a presigned URL (getSignedUrl from @aws-sdk/s3-request-presigner) that stays valid for a few minutes.
2

Serve every deck as a PDF

Sales wants a PDF link for every deck in the shared folder — without anyone exporting them by hand. An Express route does it on demand: the first request converts the deck, later requests are served from a disk cache. The cache matters, because the PDF link Apdf returns expires after one hour:

import { mkdir, readFile, writeFile } from 'node:fs/promises';
import express from 'express';
import { convertToPdf, downloadPdf } from './convert.mjs';

const DECKS_URL = 'https://files.northlight.example/decks';
const CACHE_DIR = './pdf-cache';
const app = express();

await mkdir(CACHE_DIR, { recursive: true });

// GET /decks/proposal-acme-q4-2026.pdf → the deck as PDF, converted on first request.
app.get('/decks/:name.pdf', async (req, res) => {
    const name = req.params.name.replace(/[^a-z0-9-]/gi, '');
    const cached = `${CACHE_DIR}/${name}.pdf`;

    try {
        const pdf = await readFile(cached).catch(async () => {
            const result = await convertToPdf(`${DECKS_URL}/${name}.pptx`);
            const converted = await downloadPdf(result);
            await writeFile(cached, converted);
            return converted;
        });

        res.type('application/pdf').send(pdf);
    } catch (error) {
        console.error(error);
        res.status(502).send('The deck could not be converted.');
    }
});

app.listen(3000, () => console.log('Listening on http://localhost:3000'));

Request the same deck twice and the difference is obvious:

$ curl -o kickoff.pdf -w "%{http_code} %{time_total}s\n" http://localhost:3000/decks/kickoff-globex-2026.pdf
200 2.211315s
$ curl -o kickoff.pdf -w "%{http_code} %{time_total}s\n" http://localhost:3000/decks/kickoff-globex-2026.pdf
200 0.002144s

A deck that doesn't exist answers with a 502, and the server log shows the reason the job failed:

Error: Conversion failed: {"errors":{"file":"404 Not Found"},"message":"The file could not be downloaded."}
Heads up: The cache key is the deck's name. When someone updates a deck under the same name, delete its file from pdf-cache — or version your file names — so the next request converts the new version.
3

Use it for Word and Excel too

convertToPdf() isn't limited to slides. The same endpoint converts .ppt decks, Word documents (.docx, .doc), Excel workbooks (.xlsx, .xls), OpenDocument files and RTF — pass any of them and every slide or page becomes a PDF page.

Tip: Decks set in the standard Office fonts (Calibri, Cambria, Arial) keep their layout. A custom brand font is replaced by a similar one, which can push text out of a fixed text box — convert one deck and check it before you wire it into a sales tool.

Where to go from here

The deck is a PDF now — so you can find out who actually reads it.

After the API call

Your code made the PDF.
Then it went dark.

Opened, read, signed or ignored — you never see any of it. Send the PDFs you generate as personal links, and every open, page read, comment and signature comes back to your software as a webhook. Need it signed? Same link. Same account, same API key, one more call.

Your PDF, after sending Live
document:loaded CFO
page:read p4 · 38s
signature:signed 11:48

API · Webhook · MCP