Guide
HTML to PDF in Vercel
Vercel's Serverless and Edge runtimes have a 50 MB bundle limit and no native support for launching browser processes. Running Puppeteer or headless Chrome in a Vercel function is technically possible with @sparticuz/chromium, but the compressed binary alone is around 50 MB, it hits the bundle limit, and cold starts take 5 to 10 seconds. PDFPipe handles the render in an external service, so your Vercel function stays tiny, starts instantly, and works in both the Serverless and Edge runtimes.
Why headless Chrome fails on Vercel
| Approach | Bundle size | Cold start | Edge runtime |
|---|---|---|---|
| @sparticuz/chromium | ~45 MB compressed | 5 – 10 s | No |
| puppeteer-core + chromium | Hits 50 MB limit | Very slow | No |
| PDFPipe API | ~0 MB (just fetch) | ~100 ms | Yes |
Step 1: Add your API key
Get a free key at pdfpipe.xyz/signup. Add it to .env.local and set it in your Vercel project environment variables.
# .env.local
PDFPIPE_API_KEY=pp_live_your_key_hereStep 2: Create a Route Handler
Create an API route that builds your HTML template and calls PDFPipe. The function streams the PDF bytes back to the browser as a download.
// app/api/invoice/route.ts
import { NextRequest, NextResponse } from "next/server";
const PDFPIPE = "https://api.pdfpipe.xyz/v1/pdf";
export async function POST(req: NextRequest) {
const { invoiceId } = await req.json();
// Build your HTML template — any server-side data fetch works here
const html = `
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: sans-serif; margin: 40px; }
h1 { border-bottom: 2px solid #000; padding-bottom: 8px; }
table { width: 100%; border-collapse: collapse; margin-top: 24px; }
td, th { padding: 8px 12px; border: 1px solid #ddd; text-align: left; }
</style>
</head>
<body>
<h1>Invoice ${invoiceId}</h1>
<table>
<tr><th>Item</th><th>Amount</th></tr>
<tr><td>Pro plan (annual)</td><td>$960.00</td></tr>
</table>
</body>
</html>
`;
const resp = await fetch(PDFPIPE, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PDFPIPE_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
html,
options: { format: "A4", margin: { top: "10mm", bottom: "10mm" } },
}),
});
if (!resp.ok) {
return NextResponse.json({ error: "PDF generation failed" }, { status: 502 });
}
const pdf = await resp.arrayBuffer();
return new NextResponse(pdf, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="invoice-${invoiceId}.pdf"`,
},
});
}Step 3: Call it from the client
// In your React component or client-side code
async function downloadInvoice(invoiceId: string) {
const resp = await fetch("/api/invoice", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ invoiceId }),
});
if (!resp.ok) throw new Error("Failed to generate invoice");
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `invoice-${invoiceId}.pdf`;
a.click();
URL.revokeObjectURL(url);
}Optional: run in the Edge Runtime
Since PDFPipe is a plain fetch call, the function runs in both Serverless and Edge runtimes. Add one line to move it to the Edge for lower global latency and no function timeout.
// app/api/invoice/route.ts — add this line to run in the Edge Runtime
export const runtime = "edge";
// The rest of the function is identical — fetch() works in both runtimes.
// Edge gives you: lower cold starts, global CDN execution, no 10s timeout.Optional: store the PDF and return a URL
For flows where you want to email a link or store the URL in a database, pass "store": true in the request body. PDFPipe returns a stable URL alongside the document ID.
// Store the PDF and return a URL instead of streaming bytes
const resp = await fetch("https://api.pdfpipe.xyz/v1/pdf", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PDFPIPE_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
html,
store: true, // store the document, get back a URL
options: { format: "A4" },
}),
});
const { document_id, url } = await resp.json();
// url is stable and can be emailed, stored in a database, etc.
return NextResponse.json({ url });Common questions
Can I use this with Pages Router?
Yes. Create the handler in pages/api/invoice.ts instead. The fetch call and response construction are the same; only the handler signature changes.
What about the 10-second function timeout on the hobby plan?
PDFPipe typically renders in under 2 seconds for simple documents. Complex pages with many images may take longer. For production use on Vercel Pro or Business, the function timeout can be raised to 60 seconds or more in your project settings. Alternatively, use the Edge Runtime, which has no hard timeout on Pro.
Does this work with Vercel AI SDK / streaming responses?
PDFPipe returns the PDF as a binary response, not a streamed text response. Return it as a NextResponse with Content-Type: application/pdf.
Get started
500 renders a month free. Key issued instantly, no card needed.