URL to PDF: Convert a Live Web Page to PDF in 2026

By the propzapi team · Last updated August 2026 · 10 min read

Convert a URL to PDF: headless Chromium turns a live web page into a paginated PDF.

Converting a URL to PDF looks like a one-liner. Then you run it against a real web page and get back a blank sheet, or a document with white boxes where the colors should be.

To convert a URL to PDF you load the page in a headless browser and print it. In Node that is Puppeteer or Playwright driving headless Chromium; in Python it is Playwright, because the old wkhtmltopdf path is now unsafe. The print call is the easy part. Getting a live page you did not build to fully render first is where most url-to-pdf code quietly fails.

I build propzapi, a hosted image and PDF API, so the last section leans on it. But the Node and Python code here is the real thing you would run yourself, and I will point out exactly where self-hosting gets painful.

One thing to get straight up front. Converting a URL you do not control is a different job from rendering your own HTML. Your own HTML is predictable. A third-party URL is a moving target of JavaScript, lazy images, and print styles you cannot edit. Almost every gotcha below comes from that gap.

How do you convert a URL to PDF in Node?

Use Puppeteer. Call page.goto(url, { waitUntil: "networkidle2" }), wait for a real element, then page.pdf({ format: "A4", printBackground: true }). Two defaults will bite you: per the Puppeteer PDFOptions docs, printBackground defaults to false and format defaults to letter, not A4. Set both, or your PDF loses its colors and comes out the wrong size.

Here is the whole thing, with the waits that actually matter on a live page.

import puppeteer from "puppeteer";

const browser = await puppeteer.launch();
const page = await browser.newPage();

// networkidle2, not networkidle0. Analytics + websockets keep the network
// busy on real pages, so networkidle0 may never fire.
await page.goto(url, { waitUntil: "networkidle2", timeout: 60000 });

// Wait for something real, not a fixed timeout. An SPA is an empty shell
// until this element exists.
await page.waitForSelector("main", { visible: true });

await page.emulateMediaType("screen");         // keep on-screen styles
await page.evaluateHandle("document.fonts.ready");

const pdf = await page.pdf({
  format: "A4",           // Puppeteer defaults to letter, not A4
  printBackground: true,  // without this, backgrounds and colors are dropped
  margin: { top: "12mm", bottom: "12mm", left: "10mm", right: "10mm" },
});
await browser.close();

The emulateMediaType("screen") line is not optional. Chromium prints with print CSS media by default, so any @media screen styling on the page disappears from your PDF. The Puppeteer PDF guide spells this out, and it is the source of half the "my PDF looks nothing like the page" complaints.

Playwright works the same way, with one hard limit. Its page.pdf() runs in headless Chromium only. Call it on Firefox or WebKit and you get the exact error "PDF generation is only supported for Headless Chromium". So pick Chromium for the PDF job no matter which library you standardize on elsewhere.

Why is my URL-to-PDF blank or missing content?

Because the PDF fired before the page finished rendering. On a single-page app the DOM is an empty shell until JavaScript mounts it, and networkidle0 can never settle when analytics or websockets keep the network busy. The fix is to wait on a visible element with waitForSelector, use networkidle2 instead of networkidle0, and scroll the page so lazy content loads before you print.

This is the single most common url-to-pdf failure, and it has two separate causes people mix up.

The first is timing. A React or Vue page returns almost no HTML on first load. If you print immediately, you print the loading spinner. A Puppeteer blank-PDF issue thread traces most reports back to exactly this. Waiting for a fixed number of milliseconds is a bad patch, because slow pages still lose the race. Wait for something that proves the content exists, like a main heading or a data table.

The second is scrolling. Printing does not scroll the viewport, so any image or section that only loads when it enters view never fires its IntersectionObserver. Your PDF gets gray placeholder boxes. There is an open Puppeteer request to add a scroll option to page.pdf for this exact reason. Until then, walk the page yourself.

// Load lazy images/sections before printing. Printing does not scroll,
// so anything below the fold never triggers its IntersectionObserver.
await page.evaluate(async () => {
  const step = 800;
  const max = document.body.scrollHeight;
  for (let y = 0; y < max; y += step) {
    window.scrollTo(0, y);
    await new Promise((r) => setTimeout(r, 120));
  }
  window.scrollTo(0, 0);
});

Cap that loop at the initial page height. An infinite-scroll feed will otherwise keep growing as you scroll and you will never reach the bottom.

For a page you control, the most reliable signal is one you add yourself. Set a flag like window.__READY__ = true after your app finishes its data fetch, then wait on it with page.waitForFunction("window.__READY__"). No guessing about network timing, no racing the framework. On a page you do not control, a visible content selector is the next best thing.

It also helps to know what networkidle2 actually means: no more than two open network connections for at least 500ms. That tolerates a couple of long-lived analytics or polling requests that would keep networkidle0 pinned open forever, which is exactly why it is the safer default for real-world pages.

How do you convert a URL to PDF in Python?

Drive headless Chromium with Playwright for Python, not wkhtmltopdf. The classic pdfkit.from_url() path still works, but wkhtmltopdf was archived in January 2023 with its last release back in 2020, and it carries CVE-2022-35583, an unpatched critical SSRF flaw scored 9.8. For a static page WeasyPrint is a clean option, but it runs zero JavaScript.

Python has three real choices, and which one fits depends entirely on the page.

If the URL is static or server-rendered, WeasyPrint is the lightest tool. It fetches the URL, renders the HTML and CSS in pure Python, and never launches a browser. The catch is firm: WeasyPrint runs no JavaScript at all. A chart drawn by Chart.js or a table built at runtime simply does not exist to it.

If the URL needs JavaScript, you need real Chromium, which means Playwright for Python or pyppeteer. Same rules as Node: wait for a selector, emulate screen media, scroll for lazy content, then call page.pdf().

And if you are reaching for pdfkit because an old tutorial recommended it, stop. It wraps wkhtmltopdf, so it inherits the dead engine and the 9.8 SSRF vulnerability. On a URL-to-PDF service, where the whole point is fetching arbitrary URLs, an SSRF hole is close to worst case: a crafted page can make your server request internal addresses.

Can you convert a URL that needs a login to PDF?

Yes, but you have to authenticate the browser before you navigate. Set the session cookies or an Authorization header first with page.setCookie() or context.addCookies(), then call page.goto(). Order matters: the cookies must exist before navigation, or the page redirects you to a login screen and you print that instead of the content you wanted.

This is where self-hosting earns its keep. A hosted API that only accepts a public URL cannot see anything behind your login. If the page you need is a customer dashboard, an invoice, or an internal report, you control the browser session, so you can hand it the credentials.

// Capture a logged-in page: set cookies BEFORE you navigate.
await page.setCookie(
  { name: "session", value: process.env.SESSION_COOKIE, domain: "app.example.com" },
);
await page.goto("https://app.example.com/invoice/1042", { waitUntil: "networkidle2" });

Pull the cookie from your own authenticated session or a service account. Never hard-code a real session token in source. And treat the generated PDF as sensitive: it now contains whatever that logged-in page showed.

The failure mode here is quiet. If the cookie is missing or expired, the app does not error. It redirects you to its login screen, the navigation succeeds, and you cheerfully print a PDF of the login form. So add a check after goto: assert a selector that only exists on the authenticated page, and fail loudly if it is not there.

How do you convert a URL to PDF from the Linux command line?

Run headless Chrome directly: chrome --headless --disable-gpu --print-to-pdf=out.pdf https://example.com. It works for a quick capture, but you get no control over waiting for content, and removing the default header and footer is unreliable across Chrome builds. For anything beyond a one-off, script Puppeteer instead of the raw flag.

The CLI is genuinely handy in a cron job or a shell script where the page is simple and static.

chrome --headless --disable-gpu \
  --print-to-pdf=out.pdf https://example.com

The rough edge is headers and footers. As Andre Arko documented, the raw --print-to-pdf is not the same as Chrome's Print to PDF menu, and the flag to drop the running header changes between Chrome, Edge, and Canary. On stable Google Chrome you often cannot remove it cleanly at all. The moment you need margins, headers, or a reliable wait, you are back to the DevTools protocol, which is what Puppeteer drives for you.

Should you self-host a URL-to-PDF converter or use an API?

Self-host when you need to capture logged-in pages, run at high steady volume, or keep everything in your own network. Use a webpage-to-PDF API when PDFs are a feature rather than your core product and you would rather not babysit Chromium. Hosted url-to-pdf APIs start around $9 to $25 a month; the real cost of self-hosting is operations time, not the license.

The Chromium operations tax is real, and AWS Lambda is where people first feel it. The full puppeteer package bundles a Chromium of roughly 170MB, which blows past Lambda's 250MB unzipped limit once you add your code. The usual fix is puppeteer-core plus @sparticuz/chromium, whose build is about 130.6 MiB uncompressed and ships Brotli-compressed near 33 MiB. It fits, but now you own a cold-start that unpacks Chromium into /tmp on every scale-up.

If that is not your business, a hosted API is usually cheaper once you price your own time. Here is the honest landscape for services that take a raw URL, with prices checked in August 2026.

ServiceFree tierCheapest paidModel
PDFShift50 credits/mo$9/mo (500)Subscription + overage
ScreenshotOne100 renders/mo$17/mo (2,000)Subscription
DocRaptorFree test docs$15/moSubscription
Browserless1,000 units/mo$25/mo (20,000)Usage-based
api2pdfPay-per-use~$0.001/MBPay-as-you-go
propzapi50 renders, one-time$5 / 150 rendersPay-as-you-go

Watch the metering, not just the sticker price. Several of these bill an extra unit per 5MB of output or per 30 seconds of render time, so a few heavy pages cost more than the tier implies. Pull the current numbers from each pricing page before you commit; I linked PDFShift's and Browserless's so you can compare the fine print.

With propzapi, converting a public URL to PDF is one call: post the URL to /v1/screenshot with format: "pdf". It runs the same headless Chromium, emulates screen media so backgrounds survive, and scrolls the page first so lazy content loads.

curl -X POST https://api.propzapi.com/v1/screenshot \
  -H "X-API-Key: pk_live_…" -H "Content-Type: application/json" \
  -d '{"url":"https://example.com","format":"pdf","paper":"A4"}'
# → { "url": "https://images.propzapi.com/shot_2ef7….pdf",
#     "source": "https://example.com", "format": "pdf", "paper": "A4" }
# X-Credits-Cost: 1   (a failed render costs 0)

A failed render costs nothing, the exact charge comes back in a header, and you pick the paper size from A4 through Ledger. Where it is not the right tool: a page behind your login, which needs a browser session you control, or a 60-page report that needs precise running footers, which is paged-media work for an engine like Prince. For a public page you want as a clean PDF, it is one request and no Chromium to run.

Frequently asked questions

How do I convert a URL to PDF in Node.js?
Use Puppeteer. Call page.goto(url, { waitUntil: 'networkidle2' }), wait for a real element with waitForSelector, then page.pdf({ format: 'A4', printBackground: true }). printBackground defaults to false and format defaults to letter, so set both explicitly or your PDF loses its backgrounds and comes out US-letter sized.
Why is my URL-to-PDF blank or missing content?
The PDF fired before the page finished rendering. On a single-page app the DOM is an empty shell until JavaScript mounts it, and networkidle0 may never settle because analytics and websockets keep the network busy. Wait on a visible element with waitForSelector, use networkidle2, and scroll the page so lazy content loads.
Can I convert a URL that needs a login to PDF?
Yes, but you have to authenticate the browser before you navigate. Set the session cookies or an Authorization header first with page.setCookie() or context.addCookies(), then call page.goto(). The cookies must exist before navigation. A hosted screenshot API that only takes a public URL cannot capture a page behind your login.
How do I convert a URL to PDF in Python without wkhtmltopdf?
WeasyPrint can fetch a URL and render HTML and CSS with no browser, but it runs no JavaScript, so it only works on static or server-rendered pages. For a JavaScript-heavy live URL, drive headless Chromium through Playwright for Python, or call a hosted API. Avoid wkhtmltopdf: it was archived in 2023 and carries an unpatched critical SSRF flaw.
How do I convert a URL to PDF from the Linux command line?
Run headless Chrome: chrome --headless --disable-gpu --print-to-pdf=out.pdf https://example.com. It works, but removing the default header and footer from the CLI is unreliable across builds, and you get no control over waiting for content. For anything real, script Puppeteer instead of the raw flag.
Is it cheaper to self-host Puppeteer or use a URL-to-PDF API?
Self-hosting Puppeteer has no license cost, but you own Chromium operations: memory, crashes, and the 250MB AWS Lambda limit that forces you onto a slimmed Chromium build. Hosted APIs start around $9 to $25 a month, or pay-as-you-go. The real break-even is your operations time, not the license.

Pick the method that matches the page

If the URL is a JavaScript app, convert it with Puppeteer or Playwright and wait for a real element before you print. If it is static, WeasyPrint in Python skips the browser. If it sits behind a login, self-host so you can hand the browser your session. And if it is a public page you just want as a PDF, post the URL to an API and skip Chromium entirely: grab a free key and convert your first URL to PDF in one call.

Get a free key Generate a PDF from your own HTML