Skip to content
CastPDF

Generate a PDF in Next.js from a Route Handler

To generate a PDF in Next.js, add a Route Handler such as app/api/invoice/route.ts that posts your data to CastPDF with fetch and returns the bytes with Content-Type: application/pdf. No Chromium ships in your function bundle. It uses the same PDF generation API as every other stack and runs on the Node.js runtime.

  • Updated October 2026
  • 100 free PDFs a month

PDF options in a Next.js app

A Next.js project can create PDFs in the browser, inside its own server functions, or by handing the work to a service. The right answer depends on where you deploy. On a long running Node server you can host almost anything; on Vercel or another serverless host, every dependency has to fit inside a function bundle that starts cold.

Ways to produce a PDF from a Next.js project
ApproachWhere it runsTrade off
@react-pdf/rendererServer or browser, using its own React primitivesFast and pure JavaScript, but you lay out pages with its components and a subset of CSS rather than your normal HTML
Puppeteer with full ChromiumA Route Handler on the Node.js runtimeThe browser download is far larger than the bundle limits of most serverless hosts
puppeteer-core plus a trimmed Chromium buildServerless functionsFits, but you match browser and library versions yourself and pay for slow cold starts
Browser print (window.print())The visitor’s browserNo server work, but every browser paginates differently and you cannot store or email the result
CastPDFAny runtime that has fetchOne HTTPS call per document and a plan above the free allowance

The serverless point deserves a closer look. The regular puppeteer package downloads a complete Chromium build when it installs, and that alone exceeds the unzipped size most platforms allow for one function. The usual workaround is a compressed Chromium made for Lambda style environments, which then decompresses into temporary storage on every cold start. It works, yet every Next.js or Node upgrade becomes a small compatibility project. Calling an API keeps your bundle as small as the code you wrote.

A Route Handler that returns the PDF

Create a test key in the dashboard (it starts with cpdf_test_) and put it in .env.local as CASTPDF_API_KEY, next to the ID of the invoice starter you copied. The handler below reads an invoice number from the query string, loads the record from your own data layer, renders it through the saved template and streams the result straight back to the browser.

app/api/invoice/route.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { findInvoiceForCurrentUser } from '@/lib/invoices';

export const runtime = 'nodejs';
export const maxDuration = 60;

export async function GET(request: NextRequest) {
  const number = request.nextUrl.searchParams.get('number');
  if (!number) {
    return NextResponse.json({ message: 'Add ?number=' }, { status: 400 });
  }

  // Returns null when the signed-in user does not own this invoice.
  const invoice = await findInvoiceForCurrentUser(number);
  if (!invoice) {
    return NextResponse.json({ message: 'Not found' }, { status: 404 });
  }

  const upstream = await fetch('https://api.castpdf.com/v1/pdf', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.CASTPDF_API_KEY}`,
      'Content-Type': 'application/json',
      'Idempotency-Key': `invoice-${invoice.number}`,
    },
    body: JSON.stringify({
      template_id: process.env.INVOICE_TEMPLATE_ID,
      data: invoice,
      filename: `invoice-${invoice.number}`,
    }),
    signal: AbortSignal.timeout(60_000),
  });

  if (!upstream.ok) {
    const { error } = (await upstream.json()) as { error: { code: string; message: string } };
    console.error('CastPDF failed', error.code, error.message);
    return NextResponse.json({ message: 'The PDF could not be created' }, { status: 502 });
  }

  return new Response(upstream.body, {
    headers: {
      'Content-Type': 'application/pdf',
      'Content-Disposition': `attachment; filename="invoice-${invoice.number}.pdf"`,
      'Cache-Control': 'private, no-store',
    },
  });
}

A few details matter here. runtime = 'nodejs' keeps the handler on the full Node runtime, and maxDuration asks the host for 60 seconds instead of its shorter default (the ceiling depends on your hosting plan). Passing upstream.body to new Response streams the PDF through without buffering it in memory. The ownership check comes before the API call, so nobody can download another customer’s invoice by guessing numbers, and a failed upstream call becomes a clean 502 for your UI instead of a leaked error message.

Link to it from any page with a plain anchor such as /api/invoice?number=INV-2026-0311. Use attachment in the Content-Disposition header to force a download, or inline to open the PDF in a new tab.

The Server Action alternative

A Server Action cannot hand a binary file to the browser, because its return value has to be serialisable. What it can return is a signed link. Ask CastPDF for "response": "url" and the reply is JSON with a private download address that works without your key until it expires. This suits a button like “Download confirmation” on an order page: the action runs on the server, the client receives a short object and opens the link.

app/orders/actions.ts
'use server';

import { getOrder } from '@/lib/orders';

type Result = { ok: true; url: string; expiresAt: string } | { ok: false; code: string };

export async function createOrderConfirmation(orderId: string): Promise<Result> {
  const order = await getOrder(orderId);

  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': `order-confirmation-${order.number}`,
    },
    body: JSON.stringify({
      template_id: process.env.ORDER_CONFIRMATION_TEMPLATE_ID,
      data: {
        order: { number: order.number, placed: order.placedAt, delivery: order.deliveryWindow },
        customer: { name: order.customerName, email: order.customerEmail },
        lines: order.lines,
        totals: order.totals,
      },
      filename: `order-${order.number}`,
      response: 'url',
    }),
    signal: AbortSignal.timeout(60_000),
  });

  const body = await res.json();
  if (!res.ok) return { ok: false, code: body.error.code };
  return { ok: true, url: body.url, expiresAt: body.expires_at };
}

Returning a result object instead of throwing is deliberate. In production builds Next.js replaces the message of an error thrown in a Server Action with a generic one, so the client would never learn why it failed. With the union type above, your client component can show “Try again in a minute” for rate_limited and something more specific for the rest. On success, set window.location.href to the returned url, or store it alongside the order so the customer finds it again later.

The template and the JSON it receives

Keep the order confirmation layout in a saved template rather than in your React tree. You write its HTML and CSS once in the dashboard with a live preview; after that, a colleague can adjust the sample values in Simple mode without opening your repository. Your action sends only the facts that change. This is the request body the action above produces for a typical order:

Request body for an order confirmation
{
  "template_id": "6b0f3c2e-9d41-4a7e-8f15-2c3d4e5f6a7b",
  "data": {
    "order": { "number": "ORD-48213", "placed": "2026-10-02", "delivery": "6 to 8 October" },
    "customer": { "name": "Priya Raman", "email": "[email protected]" },
    "lines": [
      { "sku": "LMP-220", "name": "Brass desk lamp", "quantity": 1, "price": 129 },
      { "sku": "BLB-E27", "name": "Warm LED bulb, 2 pack", "quantity": 2, "price": 12.5 }
    ],
    "totals": { "subtotal": 154, "shipping": 0, "tax": 30.8, "total": 184.8 }
  },
  "filename": "order-ORD-48213",
  "response": "url"
}

Inside the template, Liquid fills the values: {% for line in lines %} builds the item rows and {{ totals.total | money: "EUR" }} formats the amount. The data object replaces the template’s sample data entirely, so send every field the layout reads. Templates render in print mode by default, which keeps a long order table tidy across pages with its header repeated.

Next.js production checklist

  • Never prefix the key with NEXT_PUBLIC_. That prefix inlines a variable into the client JavaScript, where anyone can read it. Plain CASTPDF_API_KEY stays on the server.
  • Add import 'server-only' at the top of the module that calls CastPDF. If a client component ever imports it by mistake, the build fails instead of shipping your key.
  • Use the test key for preview deployments and the cpdf_live_ key only in the production environment. Most hosts let you set a different value per environment, and a redeploy picks up the change.
  • Set maxDuration to at least 60 seconds on routes that render long reports. A render may take up to 30 seconds before CastPDF stops it.
  • Do not fetch your own Route Handler from a Server Component. Import the function that calls CastPDF and run it directly; the extra HTTP hop only adds latency and another timeout.
  • Build the Idempotency-Key from the invoice or order number. A double click, or a retry after a timeout, then returns the first document instead of making and billing a second one.
  • Mind the document limits of 50 pages and 40 MB. Product photos are the usual reason an order PDF grows, so serve them at print size.

Troubleshooting Next.js PDF routes

Symptoms seen in Next.js projects and how to fix them
SymptomLikely causeFix
The key is undefined in your componentThe code runs in a client component, where server variables do not existMove the call into a Route Handler or a Server Action
Works locally, invalid_api_key (401) after deployThe variable is missing in that environment, or was added after the last buildSet it for the environment and redeploy
FUNCTION_INVOCATION_TIMEOUT on VercelThe function limit is shorter than the renderRaise maxDuration, or move very large reports to a background worker
The downloaded file will not openThe handler returned JSON or text, or used res.text() on the PDFReturn upstream.body or the arrayBuffer() with the PDF content type
template_render_error (422)A Liquid tag in the template failed on the data you sentRead details.line and details.column, then fix the tag or the field
rate_limited (429) during preview testingTest keys allow 20 requests per minuteWait for the Retry-After seconds; live keys have higher limits

Start with the quickstart to create a key and a template, and keep the API reference open for every request field. The Node.js page covers the same calls outside a framework, and the Express page shows them in a classic route with error middleware.

FAQ

Common questions

Can a Next.js client component call CastPDF directly?

It should not. Anything in a client component reaches the browser, including headers, so your key would be public. Call the API from a Route Handler or a Server Action and pass the PDF or its link to the client.

Should I use a Route Handler or a Server Action for PDF downloads?

Use a Route Handler when you want a link that downloads or opens the file directly. Use a Server Action with a signed link when a button in a form starts the work. Both keep the key on the server.

Does the Route Handler work on the Edge runtime?

The call itself only needs fetch, which the Edge runtime has. The Node.js runtime is the safer default, because it allows longer durations and your data layer probably expects Node APIs. Switch only if you have a reason to.

Will long reports hit my Vercel function timeout?

They can if the function limit is short. Set maxDuration to 60 seconds or more on that route. For very large documents, render in a background job and store the signed link for the user.

Is a document billed twice when a user clicks download two times?

Only if the two requests look different to CastPDF. Send an Idempotency-Key built from the invoice or order number and the second click returns the first document. Replays are never billed again.

Do I still need @react-pdf/renderer?

Not for documents you can describe in HTML and CSS. It remains a good library when you want to draw pages with React primitives. Many teams keep it for small client side exports and use the API for invoices and reports.

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.