Advanced assets
Real-world scenarios for getting assets into your PDFs, from remote fonts to private storage and per-deploy CDNs.
There is lots of flexibility with how your can resolve assets for your documents in Format. This page touches on some more advanced ones, contextualizing each with a real-world example. We recommend reading Assets for a primer on how Format uses assets.
Documents contain a known, but remote asset
Imagine a team that shares styling with their web app, and a component stylesheet imports a Google font. The font is the same for every PDF, but it lives at a remote URL. Refactoring the shared CSS isn't on the table this quarter.
Without intervention the font would be missing from every PDF, because Format never fetches remote references during the API render. The --remote-assets flag fetches the remote import and its referenced remote font files into the bundle at build time.
npx format compile --remote-assetsimport { FormatClient } from '@format.dev/client'
import { invoice } from './_generated'
const doc = await invoice.render(data)
const format = new FormatClient()
const response = await format.pdf(doc)The runtime code is unchanged, however the machine running compile, typically in CI needs network access to the font host, and a fetch failure fails the build.
Prefer local fonts when you can change the CSS. Fontsource packages every Google font for npm, and manual download works too. Local fonts remove the network from your build entirely.
Caching a remote stylesheet at compile time
Imagine your document pulls in a shared design-system stylesheet hosted on a CDN. It carries rules you want, colors, spacing, and a type scale, and the fonts it names already ship with your bundle. You don't want every render to depend on that CDN staying up. The remote stylesheet should be fetched once at compile time and its rules inlined into the document.
npx format compile --inline-remote-cssimport { FormatClient } from '@format.dev/client'
import { report } from './_generated'
const doc = await report.render(data)
const format = new FormatClient()
const response = await format.pdf(doc)The remote @import is replaced by the actual rules at build time, so the document is self-contained and the CDN is out of the render path.
This inlines CSS text only. It does not fetch any assets the stylesheet references. If those rules point at remote fonts or images, bundle them with --remote-assets instead, or ideally, keep them local.
PDFs generated where there's no filesystem
Imagine a reporting dashboard that generates PDFs client-side in the browser, and another team running the same document in a Vercel Edge function. Neither runtime can read assets.zip from disk, but the asset set is still fixed, so the document stays static.
- Assets are static, but the zip is hosted on a CDN instead of read from disk.
- Runs in the browser or at the edge.
setAssetsUrl()tells the document where its zip lives.
Compile for the runtime, then upload the zip as part of CI.
npx format compile --preset browser
aws s3 sync _generated/ s3://my-bucket/format-assets/v1.2.0/ --include "*/assets.zip" --exclude "*"import { FormatClient } from '@format.dev/client'
import { report } from './_generated'
report.setAssetsUrl('https://cdn.example.com/format-assets/v1.2.0/report/assets.zip')
const doc = await report.render(data)
const format = new FormatClient()
const response = await format.pdf(doc)The zip is deterministic per build, so serve it with immutable cache headers and version the path. See CDN-hosted ZIP for the full workflow.
Dynamic asset filenames, not available during dev
Imagine a statement that shows one of 500 tenant logos, picked by the request. The filename is built from data, so the exact asset isn't known at compile time. Your dev machine only has a few sample logos to preview against.
- Assets are dynamic. Each render builds a zip containing only the files it references.
- The files ship with the compiled bundle. Nothing is fetched from outside it.
- Runs anywhere.
Static mode can't help here. No single zip should carry 500 logos, and the compiler can't know which one a render needs. The template builds the reference from data.
<header>
<img src={`./logo-${data.tenantId}.png`} />
</header>npx format compile --assets dynamicimport { FormatClient } from '@format.dev/client'
import { statement } from './_generated'
// the logos live in the document's assets folder and deploy with the bundle:
// documents/statement/assets/logo-acme.png
// documents/statement/assets/logo-globex.png
// ...
const doc = await statement.render({ tenantId: 'acme' })
const format = new FormatClient()
const response = await format.pdf(doc)The call site is identical to static mode. At render time the document builds a zip in memory holding one logo, not 500.
While authoring in Studio, the assets folder only needs two or three sample logos to preview against. Production ships the full set, and onboarding a tenant means adding their logo and recompiling, which refreshes the bundle's known-asset map.
Known assets, apart from one image
Imagine a resume builder where people fill in their details, upload a photo, and download the finished PDF straight from the page. Every asset is known and ships with the bundle, the fonts and the icons, except the avatar. That one is a remote URL that differs per user.
- Assets are dynamic, and the avatar is a remote URL passed in as data.
- Runs in the browser. The PDF generates without a server round-trip.
setZipOptions()opts the per-render zip build into fetching the avatar.
The avatar is the one reference that points at https://, which the runtime zip build rejects by default.
<header>
<img className='avatar' src={data.avatarUrl} />
<h1>{data.name}</h1>
</header>npx format compile --assets dynamic --preset browserimport { FormatClient } from '@format.dev/client'
import { resume } from './_generated'
resume.setZipOptions({ remoteAssets: { enabled: true } })
const doc = await resume.render({
name: 'Ada Lovelace',
role: 'Analytical engine programmer',
avatarUrl: 'https://storage.example.com/avatars/ada.png'
})
const format = new FormatClient()
const response = await format.pdf(doc)Each render fetches the avatar and bundles it alongside the template's own files. The fetch repeats per render, and an unreachable URL fails that render, so host the photos on storage you control.
Assets with hashed names from your build
Imagine a front-end pipeline that uploads assets to a CDN with content hashes, like logo.4f3a2b1c.png, and emits a manifest. The hash changes on every deploy, so the URL can't live in the document. The document keeps saying ./logo.png, and the manifest bridges to reality.
- Compile with
--assets none. Studio still renders the document; you supply the bytes. urlResolvermaps each referenced path to this deploy's URL and fetches it.- Only the paths the HTML references are fetched.
npx format compile --assets none --preset browserimport { zip, urlResolver } from '@format.dev/zip/web'
import { FormatClient } from '@format.dev/client'
import { report } from './_generated'
import manifest from './asset-manifest.json'
const resolve = urlResolver(
Object.fromEntries(
Object.entries(manifest).map(([path, hashed]) => [path, `${env.ASSET_CDN_URL}/${hashed}`])
)
)
const doc = await report.render(data)
const assets = await zip(doc.html, resolve)
const format = new FormatClient()
const response = await format.pdf(doc, { assets })The same code serves staging and production. Only ASSET_CDN_URL differs.
The manifest keys are the plain paths your markup uses. A reference of ./logo.png (or logo.png) resolves to the key logo.png, so that's what your manifest maps. Subfolders are kept, so icons/star.png stays icons/star.png.
Private storage, with a fallback
Imagine a multi-tenant worker that keeps customer logos in a private R2 bucket, deliberately not public. There's no URL to point at, and a tenant who never uploaded a logo should fall back to a default rather than break their invoice.
- Compile with
--assets none. Studio renders; a custom resolver reads the bytes. - The files have no public URLs, so nothing URL-based could reach them. A resolver can.
- The resolver also decides what happens when a file is missing.
npx format compile --assets none --preset workerimport { zip } from '@format.dev/zip/web'
import { FormatClient } from '@format.dev/client'
import { statement } from './_generated'
const resolve = async path => {
if (path === 'logo.png') {
const object = await env.TENANT_ASSETS.get(`tenants/${tenantId}/logo.png`)
if (object) {
return new Uint8Array(await object.arrayBuffer())
}
const fallback = await env.TENANT_ASSETS.get('defaults/logo.png')
return fallback ? new Uint8Array(await fallback.arrayBuffer()) : null
}
return null
}
const doc = await statement.render({ tenantId })
const assets = await zip(doc.html, resolve)
const format = new FormatClient()
const response = await format.pdf(doc, { assets })One tenant's missing logo falls back to a default instead of failing their invoice, and the logos never need public URLs. A resolver is any (path) => Promise<Uint8Array | null>. Route each path to whatever store holds it.
Choosing at a glance
The basic case, a fixed asset set on a server with a disk, needs no flags and no resolver. format compile bundles everything and format.pdf(doc) reads it. Start at Assets for that. Reach for the recipes here when one of these is true:
| Your situation | Recipe |
|---|---|
| Fixed set, references a remote font or stylesheet you can't refactor | Static + --remote-assets |
| Fixed set, want a remote stylesheet's rules baked in at build | Static + --inline-remote-css |
| Fixed set, no filesystem at runtime | Static + CDN zip + setAssetsUrl() |
| Asset choice depends on request data | --assets dynamic |
| Request-driven and one reference is a genuinely remote URL | --assets dynamic + setZipOptions() |
| Assets at URLs that change per deploy or env | --assets none + urlResolver |
| Assets in private storage, or needing fallbacks | --assets none + custom resolver |
The thread through every recipe is the same. Remote fetching only happens where you've explicitly opted in, always before the PDF API is involved. Generation itself never touches the network.