How to Generate PDFs with Next.js on Vercel Without Running Chrome

Generating PDFs with Next.js on Vercel sounds straightforward until you try to ship Chromium inside a serverless function. At that point you run into binary size limits, cold starts, memory usage, and deployment complexity that have little to do with the actual business problem. A cleaner approach is to keep your Next.js route focused on application logic and call a remote PDF API for the rendering step.

This guide shows how to generate PDFs with Next.js on Vercel without bundling Chrome in the function. We'll use a modern App Router route handler, send either a page URL or base64-encoded HTML, and keep the architecture simple enough for invoices, reports, exports, and customer-facing documents.

Why not run Chrome inside a Vercel function?

It is possible to make Puppeteer work on Vercel with stripped-down Chromium packages, but that does not mean it is the best fit. Once you package a browser into a function, you are taking on a set of serverless-specific problems:

  • Cold starts: heavy dependencies slow down function startup.
  • Memory pressure: browser rendering can consume far more RAM than a typical JSON API route.
  • Timeout risk: PDF jobs are slower than normal request handlers and more likely to hit execution limits.
  • Maintenance overhead: Chromium versions, Linux dependencies, and deployment quirks become your problem.
  • Harder debugging: failures often show up as opaque serverless runtime issues instead of simple application errors.

If your real need is "generate a PDF from HTML in a Next.js app", embedding Chrome is often the wrong layer of complexity.

The simpler architecture

The serverless-friendly pattern is:

  1. Your Next.js route handler receives a request to generate a PDF.
  2. It loads the relevant data from your database or API.
  3. It either points Doppio at a URL or sends a base64-encoded HTML payload with PDF options.
  4. Doppio renders the PDF and returns a document URL or binary output.
  5. Your route returns the result, stores it, or triggers a follow-up workflow.

In other words, Vercel handles your application logic, while Doppio handles the browser execution. This keeps your deployment smaller, faster, and easier to reason about.

Example: Next.js App Router route handler

Here is a modern example using the App Router and a server-side route handler in app/api/invoices/[id]/pdf/route.ts. This version renders a PDF from a protected page URL.

import { NextResponse } from 'next/server';

export async function GET(_request, { params }) {
  const { id } = await params;

  const response = await fetch('https://api.doppio.sh/v1/render/pdf/sync', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.DOPPIO_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      page: {
        goto: {
          url: `https://example.com/invoices/${id}`,
          options: {
            waitUntil: ['networkidle0'],
          },
        },
        pdf: {
          format: 'A4',
          printBackground: true,
        },
      },
    }),
  });

  if (!response.ok) {
    return NextResponse.json({ error: 'PDF generation failed' }, { status: 500 });
  }

  const data = await response.json();

  return NextResponse.json({
    documentUrl: data.documentUrl,
  });
}

This is usually enough for internal dashboards, customer document pages, and existing HTML views that already render correctly in the browser.

Generate the PDF from raw HTML instead of a page URL

If your document should never exist as a public route, you can build an HTML string inside the route handler and send it as base64-encoded content. This works well for invoices, reports, and template-based exports.

import { NextResponse } from 'next/server';

export async function POST(request) {
  const { customerName, total } = await request.json();

  const html = `
  <!doctype html>
  <html>
    <head>
      <meta charset="UTF-8" />
      <style>
        body { font-family: Arial, sans-serif; padding: 40px; }
        h1 { color: #5935dd; }
        .total { margin-top: 24px; font-weight: bold; }
      </style>
    </head>
    <body>
      <h1>Invoice</h1>
      <p>Customer: ${customerName}</p>
      <p class="total">Total: ${total}</p>
    </body>
  </html>`;

  const htmlBase64 = Buffer.from(html, 'utf8').toString('base64');

  const response = await fetch('https://api.doppio.sh/v1/render/pdf/sync', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.DOPPIO_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      page: {
        setContent: { html: htmlBase64 },
        pdf: {
          format: 'A4',
          printBackground: true,
        },
      },
    }),
  });

  const data = await response.json();
  return NextResponse.json({ documentUrl: data.documentUrl });
}

This route stays lightweight because it is only assembling data and sending a rendering request. No local browser startup is needed.

When to return a URL vs when to stream the file

For many apps, returning a document URL is the simplest option. The frontend can then redirect the user, download the file, or store the link. If you need tighter control over delivery, you can also fetch the PDF and stream it from your route. Use the simpler approach first unless you have a strong reason to proxy the binary through Next.js.

Common mistakes with Next.js PDF generation on Vercel

  • Private pages are not accessible: if the source page requires authentication, you need a compatible access strategy before rendering it remotely.
  • The page is rendered too early: if your page fetches data on the client, networkidle0 may still be too early depending on your app behavior.
  • Fonts or assets are missing: remote rendering still needs access to logos, fonts, and images referenced by your HTML.
  • The wrong endpoint is used: Doppio exposes separate PDF and screenshot workflows.
  • The route does too much: do not turn your Next.js function into a mini render queue. Keep it focused on orchestration.

When async rendering is better

If your PDFs are large, generated in batches, or triggered in the background, use asynchronous rendering instead of waiting for the full document in a request cycle. That is often the right choice for scheduled reports, exports, and email workflows running on Vercel cron jobs or internal admin actions.

How this compares to Puppeteer on Vercel

If you need total browser ownership and are comfortable debugging serverless Chromium deployments, Puppeteer can still be made to work. But if your main objective is to generate PDFs reliably in a Next.js product, offloading rendering to an API is usually the more maintainable choice.

For a broader look at self-hosted browser rendering, read How to generate PDFs with Node.js and Puppeteer. For a related serverless angle, see Serverless PDF Reports with Lambda and Vercel.

Summary

The most practical way to generate PDFs with Next.js on Vercel is usually not to run Chrome in the function at all. Let Vercel handle the app, let Doppio handle the rendering, and keep the PDF flow as a lightweight serverless orchestration step.

If you want to explore the broader product capabilities behind this pattern, check the features page and the CSS Paged Media guide for better HTML-to-PDF styling.

How to generate PDFs with Next.js on Vercel without running Chrome