How to Fix Next.js Puppeteer PDF Download Link Errors
Fix Next.js Puppeteer PDF download links by checking generation, response headers, browser runtime, and deployment failures step by step.
Start by separating PDF generation from HTTP delivery. Puppeteer’s page.pdf() returns PDF bytes when you omit path. Your Next.js route must then return those bytes with Content-Type: application/pdf and Content-Disposition: attachment; filename="report.pdf". A download can still contain an HTML or JSON error response, so inspect the status, headers, first bytes, and server logs before changing the link.
This guide covers the App Router implementation, browser and deployment failures, response debugging, link behavior, PDF rendering issues, performance, and production hardening.
1. Confirm which boundary is failing
Test the endpoint directly before debugging browser markup.
curl -i http://localhost:3000/api/report-pdf -o response.bin
file response.bin
xxd -l 16 response.bin
A successful response should normally have a success status, Content-Type: application/pdf, and a body beginning with %PDF-. If the body begins with <!DOCTYPE or JSON such as {"error":...}, the route returned an error while download headers or browser behavior made it look like a PDF.
| Check | What it tells you |
|---|---|
| Status | 4xx usually indicates validation or authorization; 5xx usually indicates route, browser, or deployment failure. |
| Content-Type | Identifies the payload as PDF. Do not infer this from the filename. |
| Content-Disposition | attachment requests download behavior and supplies a suggested filename. |
| Body bytes | Verify the payload is a real PDF rather than an error page or serialized JSON. |
| Logs | Show whether launch, navigation, PDF creation, or cleanup failed. |
2. Use an App Router Route Handler that returns bytes
In the App Router, Route Handlers use standard Web API Response objects. Puppeteer documents that page.pdf() produces PDF output; omitting path keeps the result in memory instead of writing a file. See the Puppeteer PDF generation guide and the Page.pdf() API.
import puppeteer from 'puppeteer'
export const runtime = 'nodejs'
export async function GET() {
let browser: puppeteer.Browser | undefined
try {
browser = await puppeteer.launch()
const page = await browser.newPage()
await page.goto('https://example.com/report', {
waitUntil: 'networkidle2',
})
const pdf = await page.pdf({
format: 'A4',
printBackground: true,
})
return new Response(pdf, {
status: 200,
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': 'attachment; filename="report.pdf"',
'Cache-Control': 'no-store',
},
})
} catch (error) {
console.error('PDF generation failed', error)
return Response.json(
{ error: 'Failed to generate PDF' },
{ status: 500 },
)
} finally {
await browser?.close()
}
}
The snippet is a starting pattern. Add your authentication, input validation, URL or data handling, caching policy, and error policy. Do not render an arbitrary user-supplied URL without validating authorization and allowed destinations: a server-side browser can otherwise expose private network resources or authenticated content.
Return a generated document from request data
import puppeteer from 'puppeteer'
export const runtime = 'nodejs'
export async function POST(request: Request) {
let browser: puppeteer.Browser | undefined
try {
const { title, html } = await request.json()
if (typeof title !== 'string' || typeof html !== 'string') {
return Response.json({ error: 'Invalid input' }, { status: 400 })
}
browser = await puppeteer.launch()
const page = await browser.newPage()
await page.setContent(`<!doctype html><html><head><title>${title}</title></head><body>${html}</body></html>`, {
waitUntil: 'networkidle0',
})
const pdf = await page.pdf({
format: 'A4',
printBackground: true,
preferCSSPageSize: true,
})
return new Response(pdf, {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': 'attachment; filename="report.pdf"',
},
})
} catch (error) {
console.error('PDF generation failed', error)
return Response.json({ error: 'Failed to generate PDF' }, { status: 500 })
} finally {
await browser?.close()
}
}
Escape or sanitize HTML that is not fully trusted. Prefer a template with escaped values instead of interpolating raw input.
3. Make the download link request the route
<a href="/api/report-pdf">Download PDF</a>
With a same-origin response containing Content-Disposition: attachment, a normal anchor is often sufficient. An HTML download attribute can also influence same-origin behavior:
<a href="/api/report-pdf" download="report.pdf">Download PDF</a>
The server header remains the important contract. Cross-origin behavior varies by browser and response headers. For names containing spaces, quote the filename. For internationalized names, use a tested ASCII fallback plus the RFC-compatible filename* form described by MDN’s Content-Disposition reference.
4. Instrument each Puppeteer stage
Log the boundaries separately so a timeout is actionable.
const started = Date.now()
let browser
try {
console.log('pdf:launch')
browser = await puppeteer.launch()
console.log('pdf:launch-ok', Date.now() - started)
const page = await browser.newPage()
console.log('pdf:navigate')
await page.goto(targetUrl, { waitUntil: 'networkidle2', timeout: 30_000 })
console.log('pdf:navigate-ok', Date.now() - started)
console.log('pdf:generate')
const pdf = await page.pdf({ format: 'A4', timeout: 30_000 })
console.log('pdf:generate-ok', pdf.length, Date.now() - started)
return new Response(pdf, {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': 'attachment; filename="report.pdf"',
},
})
} finally {
console.log('pdf:close')
await browser?.close()
}
Use a request ID in every log line. Never return a success status until PDF generation has completed.
5. Fix “works locally, fails in production” problems
A local browser installation does not prove that the deployment image can launch Chromium. Puppeteer’s troubleshooting guide covers Linux, Docker, and missing shared libraries. On Linux, its documented diagnostic is:
ldd path/to/chrome | grep not
- Use the
nodejsroute runtime for a local browser. Next.js documentsnodejsas the default runtime; explicitly setting it makes the choice clear. - Verify that the Chromium binary exists in the deployed image and is compatible with the installed Puppeteer package.
- Install the libraries required by that exact Chromium build and base operating system. Avoid copying an old dependency list without checking the current image.
- Check available memory, temporary storage, executable permissions, sandbox configuration, and process limits.
- Check the platform’s maximum function duration. Next.js exposes
maxDuration, but the deployment platform sets the effective limit. - Confirm that outbound requests, DNS, TLS certificates, fonts, and the destination page are available from the production network.
6. Diagnose blank, incomplete, or visually different PDFs
- Blank document: verify that navigation reached the intended page and that the page did not return an application error. Wait for the content selector or a known application-ready condition before calling
page.pdf(). - Lazy content missing: scroll or wait for the application’s loading state to finish.
networkidle2is not a guarantee that every application has rendered its data. - Fonts differ: Puppeteer waits for fonts by default. Confirm the font requests succeed and use the PDF option only when you have identified a font-wait issue.
- Colors differ: PDF printing uses print media by default and may modify colors for printing. Add print CSS and
-webkit-print-color-adjust: exactwhere appropriate, then useprintBackground: true. - Wrong page size: choose one source of truth:
format, explicit width and height, or CSS@pagewithpreferCSSPageSize. - Headers or footers overlap: set PDF margins and account for header/footer template space.
7. Choose disk-backed or in-memory output
| Method | Use when | Trade-offs |
|---|---|---|
page.pdf() |
The route can hold the PDF bytes until the response is sent. | No cleanup or working-directory dependency; memory usage grows with document size. |
page.pdf({ path: 'report.pdf' }) |
You need a file for a later process or object-storage upload. | Writes relative to the process working directory; requires writable storage and cleanup. Read the file before constructing the response. |
Neither method is universally faster. Measure in your deployment with the same browser version, page, document size, and concurrency.
8. Improve reliability and throughput
- Close every browser in a
finallyblock, including error paths. - Set separate limits for browser launch, navigation, font readiness, and PDF generation so logs identify the slow stage.
- Reuse a browser process only with an explicit lifecycle strategy; create isolated pages and close them after each request.
- Bound concurrent jobs to avoid exhausting memory and file descriptors.
- Cache deterministic documents when their source data has a version or last-modified key.
- Return structured JSON errors with an HTTP error status. Do not attach PDF download headers to error responses.
- Record response size and elapsed time, but do not claim a benchmark without a documented workload and environment.
9. Inspect the response with cURL, Python, and Node.js
cURL
curl -i -L http://localhost:3000/api/report-pdf -o report.pdf
file report.pdf
Python
import requests
response = requests.get('http://localhost:3000/api/report-pdf', timeout=90)
response.raise_for_status()
print(response.headers.get('content-type'))
print(response.content[:5])
with open('report.pdf', 'wb') as output:
output.write(response.content)
Node.js
const res = await fetch('http://localhost:3000/api/report-pdf');
const body = Buffer.from(await res.arrayBuffer());
console.log(res.status, res.headers.get('content-type'), body.subarray(0, 5).toString());
if (!res.ok) throw new Error(`PDF request failed: ${res.status}`);
await import('node:fs/promises').then(fs => fs.writeFile('report.pdf', body));
10. Troubleshooting checklist
| Symptom | Likely cause | Fix |
|---|---|---|
| Tiny or corrupt PDF | HTML or JSON error body returned with download headers | Inspect status, headers, first bytes, and logs; return JSON errors with a non-2xx status. |
| Link opens a page | Missing or incorrect Content-Disposition |
Send attachment; filename="report.pdf" and test the real browser and origin. |
| Launch error after deployment | Missing Chromium libraries, binary, permissions, or incompatible runtime | Run the documented ldd check and verify the deployed image and Node runtime. |
| Request hangs | Launch, navigation, font loading, PDF generation, or platform timeout | Log each stage and tune only the timeout tied to the failure. |
| PDF is blank | Capture occurred before application content rendered | Wait for a selector or application-ready signal and inspect browser/application errors. |
| Fonts or colors are wrong | Print media styles, failed font requests, or print color adjustment | Verify font loading, add print CSS, and configure print backgrounds and color adjustment. |
| Works for small reports only | Memory, response size, or function duration limit | Measure document size and stage timings; bound concurrency or move long jobs to an asynchronous workflow. |
Or skip the browser setup
ScreenshotNeo provides a website screenshot API and MCP server. It can return PNG, JPEG, WebP, or PDF from one GET request, including a URL such as your rendered report page.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://example.com/report -o report.pdf
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://example.com/report"}, timeout=90)
open("report.pdf", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://example.com/report' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
Before capture, cookie and consent banners, newsletter popups, and chat widgets are removed. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed, and response headers identify the page verdict and billing result. Its MCP server includes take_screenshot, get_page_info, and capture_pdf for Claude, Cursor, and other MCP clients. See the ScreenshotNeo documentation for request options.
ScreenshotNeo includes 1,000 screenshots per month free with no card; paid plans start at $5 for 3,000 shots. Create a free ScreenshotNeo account.
FAQ
Does page.pdf({ path }) have to be used for downloads?
No. Omitting path returns PDF bytes that can be placed directly in a Web API Response. Use path only when a file is useful for a later operation.
Why does a download have a 200 status but contain JSON?
The route likely applied download headers before an error response was produced. Inspect the first bytes and return errors through a separate non-2xx response path.
Can I use this exact handler in the Pages Router?
The HTTP contract is the same, but Pages Router response APIs differ by Next.js version. Check the documentation for the version installed in your project before adapting the handler.
What runtime should a local Puppeteer browser use?
Use the Node.js runtime. Verify the deployment image, browser binary, native libraries, and platform duration limit together.
When should PDF generation become an asynchronous job?
Consider a job queue or asynchronous endpoint when documents regularly approach the platform duration, memory, or concurrency limits. Keep the same generation and response validation checks in the worker.


