Skip to content

PDFs & images

Your app can already read a document — AI’s read job turns a PDF into text. PDFs & images is the other direction: you write HTML, and you get back a real file.

That’s what stands behind an invoice, a receipt, a ticket with a barcode, a certificate, a packing slip, a name badge, or the image that shows up when someone posts a link to your app. Without it the honest answer was a print stylesheet and “use your browser’s Print to PDF”.

Rendering is an import, not a hand-written fetch():

import { renderPdf, renderImage, downloadRender, RenderError } from "render";
const pdf = await renderPdf(invoiceHtml(order), { background: true });
downloadRender(pdf, `invoice-${order.id}.pdf`);
Export What it gives you
renderPdf(html, options) a Blob of application/pdf
renderImage(html, options) a Blob of image/png, or of the type you asked for
downloadRender(blob, filename) hands those bytes to the user as a download
RenderError what a refused render throws

A render answers bytes, never JSON — there is nothing to unwrap and nothing to parse. The Blob carries its own type, because the server set it, so it goes straight anywhere a Blob goes:

const card = await renderImage(cardHtml(post), { width: 1200, height: 630 });
const preview = URL.createObjectURL(card);
await fetch(`/api/_files/cards/${post.id}.png`, { method: "PUT", body: card });

Like AI, render transforms and never stores. Nothing is kept anywhere unless you keep it in Files yourself.

The import calls these for you. They’re written down because the app’s server.mjs is a lone module with no module graph — it can’t import the client, so it calls the same paths through env.PLATFORM.fetch():

Method & path Body Returns
POST /api/_render/pdf {"html":"<!doctype html>…"} — optional "size" (letter, legal, tabloid, a3, a4, a5, a6), "landscape", "margin", "background", "header", "footer" the PDF bytes, application/pdf
POST /api/_render/image {"html":"<!doctype html>…"} — optional "width", "height", "type" (png, jpeg, webp), "quality", "selector", "fullPage" the image bytes, image/png by default

In the browser, never call them directly. Every option in that table is a key on the options object the import takes, and both also accept wait.

There is no way to point this at an address and screenshot it, and that’s deliberate: the browser arrives with no session, so it would see the signed-out version of your own page and none of the user’s data. You already hold the data — render it to a string and pass the string.

That HTML is a whole document, not a fragment. The browser has none of your app’s stylesheets, no bundler, and no fonts beyond what you link, so send a full page with its own <style>:

<!doctype html>
<style>
@page { size: A4; margin: 18mm }
body { font: 13px/1.5 system-ui; color: #111 }
.row { display: flex; justify-content: space-between }
.total { border-top: 2px solid #111; font-weight: 600 }
</style>
<h1>Invoice 0042</h1>
<div class="row"><span>Design work</span><span>£1,200.00</span></div>
<div class="row total"><span>Total</span><span>£1,200.00</span></div>

Images and web fonts load if their URLs are public. Your assets are, so a logo works. A file behind sign-in is not — embed anything private as a data: URI instead. A private or local address is refused outright, so nothing on your own network can be reached from inside a render.

This is a real browser, so the print model works properly:

  • @page { size: A4; margin: 18mm } — page size and margins
  • page-break-after: always / break-inside: avoid — control where pages split
  • -webkit-print-color-adjust: exact — keep colours the printer would drop

Backgrounds are off by default, which is the browser’s own print default. Pass { background: true } to keep the fills and shading you designed. Pass neither size nor margin and your own @page rule decides the paper; pass either one and it wins over the stylesheet.

For a multi-page document, put every page in one HTML document and let the page breaks do the work — one request, one PDF. Don’t render page by page and stitch.

renderImage shoots a viewport width × height (1200 × 630 by default — a share card). { selector: ".card" } shoots that one element instead, and { fullPage: true } shoots the whole scroll height. quality applies to jpeg and webp, never to png.

A page that draws itself needs { wait: 1000 } — the shot is taken as soon as the document settles, so a chart a script builds may not be there yet. It’s capped at 15 seconds, and it is time you pay for on every render, so reach for it only when the page really does need it.

A refused render throws a RenderError carrying .status and the server’s own message:

try {
const pdf = await renderPdf(html);
downloadRender(pdf, "receipt.pdf");
} catch (err) {
if (err instanceof RenderError && err.status === 402) {
show("Documents are paused for this month.");
} else {
show(err.message);
}
}
.status What happened
401 nobody is signed in
402 the month’s budget is spent — err.budget is { used, monthly }
413 that HTML is over the size cap
429 more than 20 renders a minute from one caller
502 the browser could not lay that document out

err.guard === true means the bot check is uncleared — await guard(), then try again.

Every render spends one against a monthly budget you set in the PDFs & images tab. Past it the call throws a 402, which is not retryable and not a bug — show a plain line saying documents are paused. The count resets on the 1st (UTC), and the PDFs & images tab shows what’s been used this month. The app itself can’t read what’s left; that number is yours, not an endpoint.

A render takes real seconds, because a browser has to start. Callers are throttled to 20 a minute. Render on the click that asks for it — never one per row of a list.

  • The HTML you post is capped at 512 KB. That’s a lot of markup but not a lot of base64 — link images rather than inlining photographs.
  • PDFs & images need sign-in. The browser runs on — and bills — your own Cloudflare account, so a render always names a signed-in caller; with sign-in off every render answers 401.
  • A render that fails answers 502 with a plain message. Treat it like a failed upload, not something to retry in a loop.
  • It runs on — and bills — your own Cloudflare account, like every other capability.