Skip to content
CastPDF

Generate PDFs in an Express route

To generate a PDF in Express, write a route that posts your data to CastPDF with Node’s built-in fetch, then send the buffer with res.attachment() so the browser downloads it. Failures flow to one error middleware. It is the same PDF generation API every backend can call, so no Puppeteer process lives next to your app.

  • Updated October 2026
  • 100 free PDFs a month

Choosing a PDF approach for Express

Express gives you routing and middleware, nothing more, so PDF generation is whichever library you plug in. The decision mostly comes down to two questions: do you want to design documents in HTML and CSS, and are you prepared to run a browser inside the same process that serves your API?

PDF approaches that Express apps commonly use
ApproachDesign inOperational cost
Puppeteer in the routeHTML and CSSA Chromium process per server that competes with your API for memory; crashes or hangs affect every route
A separate Puppeteer workerHTML and CSSIsolates the browser, but now you run, scale and monitor a second service
PDFKit piped into resJavaScript drawing commandsLightweight and streams nicely; tables, wrapping and page breaks are your code to write
CastPDFHTML and CSS, or a saved template plus JSONOne outgoing request per document and a plan above the free allowance

Running Chromium inside an Express process is how many projects begin, and for low traffic it can be enough. Under real load the browser becomes the noisiest tenant on the box: memory climbs, an occasional page renders blank, and a stuck tab ties up a request until something times out. Moving the browser behind an API keeps your Express servers doing what they are good at, which is answering requests quickly.

A statement download route

The example serves monthly account statements for a lending or savings app. It assumes Express 5 and Node 20 or later, with "type": "module" in package.json so the file can use import. Keep the key in CASTPDF_API_KEY (start with a cpdf_test_ key from the dashboard) and the statement template ID in another variable.

app.js (ESM, Express 5, Node 20+)
import express from 'express';
import { requireUser } from './auth.js';
import { findStatement } from './statements.js';

export class CastPdfError extends Error {
  constructor(status, code, message) {
    super(message);
    this.status = status;
    this.code = code;
  }
}

async function renderPdf(body, idempotencyKey) {
  const res = await fetch('https://api.castpdf.com/v1/pdf', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.CASTPDF_API_KEY}`,
      'Content-Type': 'application/json',
      'Idempotency-Key': idempotencyKey,
    },
    body: JSON.stringify(body),
    signal: AbortSignal.timeout(75_000),
  });
  if (!res.ok) {
    const payload = await res.json().catch(() => ({}));
    const error = payload.error ?? { code: 'unknown', message: res.statusText };
    throw new CastPdfError(res.status, error.code, error.message);
  }
  return Buffer.from(await res.arrayBuffer());
}

const app = express();

app.get('/accounts/:accountId/statements/:month/pdf', requireUser, async (req, res) => {
  const statement = await findStatement(req.user.id, req.params.accountId, req.params.month);
  if (!statement) {
    res.sendStatus(404);
    return;
  }

  const pdf = await renderPdf(
    {
      template_id: process.env.STATEMENT_TEMPLATE_ID,
      data: statement,
      filename: `statement-${statement.period.month}`,
    },
    `statement-${statement.account.number}-${statement.period.month}`,
  );

  res.attachment(`statement-${statement.period.month}.pdf`);
  res.send(pdf);
});

export default app;

Notice there is no try block. Express 5 forwards a rejected promise from an async handler to your error middleware automatically, so a CastPDF failure or a timeout lands in one place. On Express 4 that does not happen; wrap the body in try/catch and call next(err), or the rejection goes unhandled. res.attachment() sets Content-Disposition with the filename and picks application/pdf from the .pdf extension, so you do not have to write either header yourself.

Error middleware that speaks CastPDF

Express recognises error middleware by its four arguments. Register it after your routes. This one translates the API’s errors into answers your frontend can act on: a busy or rate limited service becomes a 503 with a Retry-After hint, a timeout becomes 504, and anything else from the API becomes a 502. Your own bugs fall through to the default handler.

errors.js
import { CastPdfError } from './app.js';

export function pdfErrors(err, req, res, next) {
  if (res.headersSent) {
    next(err);
    return;
  }
  if (err.name === 'TimeoutError') {
    res.status(504).json({ message: 'The document took too long. Please try again.' });
    return;
  }
  if (err instanceof CastPdfError) {
    console.error('castpdf', err.status, err.code, err.message);
    if (err.status === 429 || err.status === 503) {
      res.set('Retry-After', '10').status(503).json({ message: 'Busy right now, try again shortly.' });
      return;
    }
    res.status(502).json({ message: 'The document could not be created.', code: err.code });
    return;
  }
  next(err);
}

// In server.js, after the routes:
// app.use(pdfErrors);
// app.listen(process.env.PORT ?? 3000);

The headersSent check covers the rare case where an error happens after the response has started; Express then needs its default handler to close the connection. AbortSignal.timeout() rejects with an error named TimeoutError, which is why the name check works without importing anything. Log err.code rather than the request body, because statements contain personal financial data.

The statement template and its data

The route sends a single JSON object; the layout lives in a saved template you edit in the dashboard with a live preview. A transaction list can easily run to several pages, which is where print mode earns its keep: the column headings repeat on each page, rows stay whole and the footer can say “page 2 of 4”. This is the shape findStatement returns for one month:

Request body for a monthly statement
{
  "template_id": "4a8c2e6f-1b3d-4f5a-9c7e-2d4f6a8b0c1e",
  "data": {
    "account": { "holder": "Marta Silva", "number": "SAV-00731", "currency": "EUR" },
    "period": { "month": "2026-09", "opening_balance": 4120.55, "closing_balance": 4688.1 },
    "transactions": [
      { "date": "2026-09-01", "description": "Standing order in", "amount": 500 },
      { "date": "2026-09-14", "description": "Card refund", "amount": 42.9 },
      { "date": "2026-09-30", "description": "Interest", "amount": 24.65 }
    ]
  },
  "filename": "statement-2026-09"
}

In the template, {{ t.amount | money: account.currency, "pt-PT" }} formats each amount for the reader’s locale, and {{ t.date | format_date: "long" }} writes each date out in full. Send every field the template uses, because data replaces the sample data as a whole.

Redirect to a signed link instead of proxying bytes

For an archive page with many statements, passing every PDF through your server is wasted bandwidth. Add response: 'url' to the body, read url from the JSON reply and answer with res.redirect(303, body.url). The browser downloads straight from CastPDF using a signed link that needs no key and stops working when the stored file expires.

Production checklist for Express

  • Load the key from the environment or a secrets manager and keep it out of any bundle served to browsers. Rotate from the test key to a cpdf_live_ key at launch without code changes.
  • Check every timeout between the user and your route. A render takes up to 30 seconds, and a reverse proxy such as nginx closes upstream reads after 60 seconds by default.
  • Put a rate limiter on PDF routes, for example express-rate-limit. A script that hammers the download URL would otherwise create live documents on your account.
  • Validate route parameters before calling the API. A month that does not match YYYY-MM should get a 400 from you, not a wasted request.
  • Use an idempotency key built from the account number and month. A browser that retries a slow download then gets the first document, and it is billed once.
  • Keep statements below 50 pages and 40 MB. Split very long histories by month rather than exporting a whole year into one file.

Troubleshooting Express PDF routes

Errors that show up in Express apps and how to fix them
SymptomLikely causeFix
Cannot use import statement outside a moduleThe project is CommonJSAdd "type": "module" to package.json or rename files to .mjs
The process logs an unhandled rejectionExpress 4 does not forward errors from async handlersUpgrade to Express 5, or catch and call next(err)
ERR_HTTP_HEADERS_SENTA response was written, then the handler tried to send anotherReturn after each res.send and check res.headersSent in error middleware
The saved file is corruptedThe body was read with res.text() or sent as JSONUse arrayBuffer() and send a Buffer
TimeoutError after 75 secondsA very slow render, often waiting for a remote imageHost images on a fast public URL or embed them as data: URLs
invalid_request (400)An unsupported field in the body, such as margin instead of marginsCompare the body with the API reference

Get a key and a template from the quickstart, and check field names in the API reference. The Node.js page shows the core calls without a framework, and the Next.js page does the same job with Route Handlers and Server Actions.

FAQ

Common questions

Does this work with Express 4?

Yes. The fetch call is identical. The only difference is error handling: Express 4 does not pass rejected promises to your error middleware, so catch them in the handler and call next with the error.

Can I stream the PDF to the client instead of buffering it?

You can pipe the response body through with Readable.fromWeb from node:stream. For typical documents the buffer is small and simpler to handle. For archives, redirecting to a signed link avoids proxying altogether.

Is CommonJS supported, or only ESM?

Both work, since fetch is global in Node 20. In CommonJS, use require for Express and wrap top level await in an async function. The request itself does not change.

How do I protect the PDF route from abuse?

Require a signed in user, check that the document belongs to them, and add a rate limiter to the route. Live documents count towards your plan, so an open endpoint can cost money.

Can my Express app render HTML with EJS or Handlebars and convert it?

Yes. Render the view to a string with app.render or the engine directly, then send it as html without a data field. CastPDF prints that markup exactly as your engine produced it.

What happens if two servers request the same statement at once?

With the same Idempotency-Key, only one document is made. While the first request is still rendering, the second gets a 409 response; retried a moment later, it receives the finished first document.

Make your first PDF in 5 minutes

Pick a template, add your details and download your PDF. You get 100 free PDFs every month, and you don’t need a card.