ScreenshotNeo

BlogHow-to

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.

By the ScreenshotNeo team1 October 20268 min read

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.

<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 nodejs route runtime for a local browser. Next.js documents nodejs as 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. networkidle2 is 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: exact where appropriate, then use printBackground: true.
  • Wrong page size: choose one source of truth: format, explicit width and height, or CSS @page with preferCSSPageSize.
  • 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 finally block, 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.

Primary references