Assets
How to bundle assets from Format Studio.
You might've already read about Format's approach to assets. This page runs you through how to apply those concepts to the documents you have built in Format Studio and want to deploy to production servers.
Compiling assets
When you bundle your documents, you have a few choices about how to handle your assets. This is controlled using the --assets flag.
This flag gives you three choices about how to deal with the assets your documents use.
- Static: zips up your assets upfront, at compile time
- Dynamic: outputs raw assets and automatically zips them at runtime, inside your app, right before calling the API
- None: outputs no assets or ZIP, leaving you to handle
Static
Using the static setting is meant for when every asset is known upfront at build time. Imagine an invoice PDF with a company logo at the top, a font face file and a few other logos in the footer.
Assuming these have all been referenced correctly, as per the Images and Fonts pages, running compile will create a ZIP archive of assets from:
- Module imports (
import banner from './banner.jpg') - The document's
assetsdirectory - The
sharedAssetsDir
An assets ZIP will be created per-document. It's a portable archive of all the assets that document needs to render.
npx format compile --assets static
# or just:
npx format compileResults in:
_generated
invoice
another-pdf
Note: The above is a simplification of the actual compiled output.
Using static is safe and performant, as we know our assets always exist when we generate our PDF and the ZIP has been created upfront, so that compute doesn't eat into our end-to-end generation.
There are two main downsides to using static:
-
Everything in your document's
assetsdirectory andsharedAssetsDiris zipped, regardless of whether its actually used in the document. This can potentially increase the file size of the ZIP for no good reason. -
You can't use assets that aren't known upfront, such as a logo that's been uploaded by a client or a customer avatar.
Remote references at compile time
Static mode bundles every asset upfront, so remote URLs have to be fetched then too. By default they aren't. Compile warns and lists any remote references it finds, and those assets will be missing from the generated PDF.
Pass --remote-assets to fetch each remote reference and bundle it into the document's assets.zip:
npx format compile --remote-assetsThis flag applies to static mode only. In dynamic mode the ZIP is built at render time, so remote assets are opted into there instead, using remoteAssets (see below).
Dynamic
Dynamic mode builds the assets ZIP at render time, inside your own runtime, instead of at compile time. When you compile, the known asset files are placed next to your bundle with a record each one lives. It won't zip anything yet.
npx format compile --assets dynamicResults in:
_generated
invoice
assets
another-png
assets
shared-assets
When your bundle is called at runtime, it scans the HTML that render produced and builds a ZIP on the fly. The ZIP will only contain the assets that the document references.
There are two reasons to choose dynamic over static.
-
Smaller, per-render ZIPs. Static mode zips every asset in your document assets directories, used or not. Dynamic mode zips only what the rendered page references. A statement template that ships 500 tenant logos but uses one per render builds a ZIP holding that single logo.
-
Remote and uploaded assets. A reference can point at a URL you only know at render time, like a customer-uploaded avatar passed in as data. See below.
The trade-off is that building the ZIP now happens during PDF generation, in your runtime, rather than once upfront. Although, for most documents this is negligible.
Remote references at render time
By default, the runtime ZIP build only includes local assets. If a rendered document references a remote URL with remote assets off, the build throws rather than reaching out to the network unprompted. A ZIP missing that asset would produce a broken PDF, so Format fails loudly at the source instead of shipping a silent gap.
This matters for assets you only know at render time. A user's avatar is the classic case. They uploaded it earlier, it lives on your CDN, and the URL is different for every render, so you can't bundle it upfront. The document has to point at the live URL.
Pass that URL in as render data and reference it in your document:
<img src={data.avatarUrl} />Then set remoteAssets.enabled via setZipOptions for the render. @format.dev/zip fetches the avatar and bundles it into the assets ZIP for the final PDF render:
import { FormatClient } from '@format.dev/client'
import { membershipCard } from './_generated'
const format = new FormatClient()
// Fetch remote references and bundle them into each render's assets.
membershipCard.setZipOptions({ remoteAssets: { enabled: true } })
// avatarUrl comes from your database, a photo the user uploaded earlier.
const card = await membershipCard.render({
name: user.name,
avatarUrl: user.avatarUrl // e.g. https://cdn.yourapp.com/avatars/8c1f2a.jpg
})
const pdf = await format.pdf(card)The avatar lands in the PDF alongside your bundled fonts and icons. Leave remoteAssets off and the same render throws, surfacing the remote reference instead of silently dropping it.
None
None mode tells Studio to leave assets alone. It emits no ZIP, builds no asset map, and skips asset checks. You're responsible for making sure every asset is reachable when the PDF is generated.
npx format compile --assets noneReach for this in two cases.
Your assets are already hosted. If every image and font in your documents is an absolute URL, such as https://cdn.example.com/logo.png, there's nothing for Studio to bundle. The renderer fetches them directly. We do not recommend this.
You manage the ZIP yourself. If you'd rather build and host the assets archive on your own terms, compile with none and point each render at your archive with setAssetsUrl().
Because nothing is bundled or checked, a broken or unreachable asset path won't surface until render time. None mode trades any Studio safety nets for full control.
Choosing a mode
Here's how the three modes compare.
static (default) | dynamic | |
|---|---|---|
| When the ZIP is built | Compile time, once | Every render, in your runtime |
| What's in the ZIP | Every imported asset, and everything in your document assets directories | Only what the rendered page actually references |
| Remote or uploaded assets | Fetched at compile time, opt in with --remote-assets | Fetched per render, opt in with setZipOptions() |
| Reference validation | Yes | Yes |
| Per-render cost | No, but potentially larger ZIP | Yes, but potentially smaller ZIP |
And here's which one to pick.
| If… | Use |
|---|---|
| All your assets are known upfront and ship with the document | static |
| You want the simplest setup and predictable, fast renders | static |
| You have many shared assets but each document uses only a few | dynamic |
| References point at uploaded or remote URLs | dynamic |
| Every asset is already hosted at a URL | none |
| You build and host the assets archive yourself | none |
CDN-hosted ZIP
If you're using the static assets mode and your runtime can't read from the filesystem (browser clients, edge functions, or serverless platforms without persistent disk), upload your asset ZIPs to a CDN or object storage during CI and point to them at runtime with setAssetsUrl().
- Compile your documents with
--assetsunset or set tostatic. Asset ZIPs are generated per-document in_generated. - Upload the ZIPs to a CDN (S3, R2, Vercel Blob, etc.).
- Call
setAssetsUrlat runtime beforerender().
invoice.setAssetsUrl('https://cdn.example.com/assets/invoice/assets.zip')Uploading assets
Upload the asset ZIPs after compiling, as part of your build or CI pipeline:
# S3
aws s3 sync _generated/ s3://my-bucket/format-assets/ \
--include "*/assets.zip" --exclude "*"
# Cloudflare R2
wrangler r2 object put assets/invoice/assets.zip \
--file _generated/invoice/assets.zip
# Vercel Blob
# Upload programmatically using @vercel/blobCache headers
Asset ZIPs are reproducible: the same assets always compile to the same bytes. So you can cache them aggressively, as long as each URL changes when its contents change. Put a version or a content hash in the upload path, then serve the ZIPs with immutable cache headers so browsers and CDNs never re-download one they already have.
Cache-Control: public, max-age=31536000, immutableVersion the path per release:
aws s3 sync _generated/ s3://my-bucket/format-assets/v1.2.0/ \
--include "*/assets.zip" --exclude "*"Because the bytes are reproducible, you can also hash each ZIP and use the hash as its path. Unchanged assets keep the same URL from one build to the next, so you skip both the re-upload and the re-download.