Images & data

Where files live, and how a reusable template gets filled with one customer's real records.

@storvexa/pdf-builder Chapter 10 of 14
Documentation

Paths, not URLs

The document stores an opaque path for every image — 'logos/acme-2024.png'. Never a URL, never base64.

Three reasons this matters, and each one bites eventually. A URL goes stale the day you move buckets or rotate a CDN. A signed URL expires, so a document saved on Monday breaks on Tuesday. And base64 inflates the JSON until saving a document becomes a performance problem.

The path means whatever you want it to mean — an S3 key, a database id, a filename. The package never interprets it. It hands it to resolveImageUrl when something needs displaying, and that is the whole contract.

Uploading

<PdfBuilder imageStoragePath="documents/42" // optional hint passed back to you in ctx onUploadImage={async (file, ctx) => { const body = new FormData() body.append('file', file) body.append('folder', ctx.storagePath ?? 'documents') const res = await fetch('/api/uploads', { method: 'POST', body }) const { path, width, height } = await res.json() return { path, width, height } // only `path` is required }} />
fileThe browser File.
ctx.storagePathWhatever you passed as imageStoragePath.
ctx.blockIdWhich block is uploading, if you want per-block foldering.
returns{ path, width?, height? } — dimensions let the canvas reserve the right space before the image loads.
Validate on the server, not just here. The package performs no upload of its own and enforces no size or type limit — it is your endpoint, so it is your validation. Rejecting by throwing surfaces an error to the user.

Resolving

<PdfBuilder resolveImageUrl={(path) => `https://cdn.example.com/${path}`} />

Called every time an image renders — canvas, preview and PDF capture. Keep it cheap and synchronous.

Signed URLs need care. If you sign per call, sign with a lifetime that outlasts a PDF export, and do not do expensive work inside this function — it runs on every render, not once per image.

Deleting is optional, and only called when the user removes an image:

<PdfBuilder onDeleteImage={async (path) => { await fetch(`/api/uploads/${path}`, { method: 'DELETE' }) }} />
Think before you actually delete. The same path may be used by another document, or by an older version of this one. Soft-delete or reference-count; do not unlink on the first call.

URL strategies

resolveImageUrl is one function, and it is the whole of your storage policy. Here is each realistic arrangement, written out.

Files served from your own domain

The simplest case, and the right default while you are getting started.

// stored path: 'logos/acme.png' // public URL: https://yourapp.com/uploads/logos/acme.png <PdfBuilder resolveImageUrl={(path) => `/uploads/${path}`} />

A CDN or object store

const CDN = 'https://cdn.example.com' <PdfBuilder resolveImageUrl={(path) => `${CDN}/${path}`} />
This is why the document stores a path. Move buckets, change CDN, add an image-resizing proxy — you change this one function and every document ever saved picks it up. Had you stored URLs, you would be rewriting historical rows.

Per-tenant or per-customer folders

imageStoragePath is a hint you send in and get back on upload. Combine it with the same prefix on the way out.

<PdfBuilder imageStoragePath={`tenants/${tenant.id}`} onUploadImage={async (file, ctx) => { const body = new FormData() body.append('file', file) body.append('folder', ctx.storagePath) // 'tenants/42' const { path } = await (await fetch('/api/uploads', { method: 'POST', body })).json() return { path } // e.g. 'tenants/42/logo.png' }} resolveImageUrl={(path) => `${CDN}/${path}`} />
Decide once whether the tenant prefix lives in the path or in the resolver. Put it in the stored path (as above) and documents are portable but tied to a tenant. Strip it from the path and add it in resolveImageUrl instead, and the same document can be rendered for a different tenant. Both are valid; mixing them produces broken images that are very hard to trace.

Private files with signed URLs

// Sign once per session, not per render — this function runs on EVERY render. const [token, setToken] = useState(null) useEffect(() => { fetch('/api/storage-token').then(r => r.json()).then(d => setToken(d.token)) }, []) <PdfBuilder resolveImageUrl={(path) => `${CDN}/${path}?token=${token}`} />
Sign for longer than a PDF export takes. Capture waits for every image to load. A URL that expires mid-export produces a PDF with missing images and no error — give the token minutes, not seconds.

An image proxy, for size or format

<PdfBuilder resolveImageUrl={(path) => `https://images.example.com/${encodeURIComponent(path)}?w=1600&fm=webp&q=82`} />
Do not down-scale below print resolution. The canvas is roughly 794px wide for A4, but the PDF wants the real pixels. A 400px logo looks fine on screen and prints soft.

Absolute URLs, when you need them

Return absolute URLs if the document is ever rendered off-origin — a server-side PDF renderer, an email preview, another domain. A relative /uploads/… resolves against whatever page it lands on, which is not always yours.

Handling a missing file

<PdfBuilder resolveImageUrl={(path) => { if (!path) return '' if (path.startsWith('http')) return path // legacy rows that stored a URL return `${CDN}/${path}` }} />
That startsWith('http') line is worth keeping. If you are migrating from a system that stored URLs, it lets old and new documents coexist while you backfill — without it, every historical document loses its images on the day you switch.

An image library

Let users pick from images they already uploaded rather than uploading the same logo forty times.

<PdfBuilder imageLibrary={{ enabled: true, onBrowse: async () => (await fetch('/api/images')).json(), // [{ path, name?, thumbnailUrl? }] }} />

Placeholder tokens

This is a design tool, so block defaults never contain invented facts. They contain tokens — and the canvas shows readable sample text in their place, so a designer can judge the layout without anyone's real data.

// stored in the document content: { company: '{{ company.name }}' } // what the canvas draws 'Northwind Trading Co.'
The document keeps the token; the page shows the sample. Nothing ever writes a sample into the document. That is what makes a template you designed today mappable to a real record tomorrow.
import { PLACEHOLDER, PLACEHOLDER_SAMPLES, token, interpolateText } from '@storvexa/pdf-builder' console.log(PLACEHOLDER.company.name) // the token string console.log(PLACEHOLDER_SAMPLES) // every token and its sample interpolateText('Hello {{ customer.name }}', vars)

Replacing a placeholder with real data

This is the question every integration reaches. Your user designed a template with {{ customer.name }} in it. Now you have a real customer. How do their details get onto the page?

There are two ways, and which one you want depends on who is looking.

A — bind in the builderB — substitute at render time
HowThe variables propinterpolateContent()
Where it runsIn the browser, while editingAnywhere, including your server
User seesThe finished document as they editNothing — there is no user
Document keepsThe tokens (still reusable)The tokens (still reusable)
Use for"Preview and tweak this one invoice""Generate 5,000 invoices tonight"
Both leave the saved document holding tokens. Neither writes a customer's name into the template. That is the point — the template stays reusable, and the data stays in your database where it belongs.

Method A — bind in the builder

Pass the real values as sample. The canvas draws them immediately, so the user is editing the actual document rather than a mock-up of one.

<PdfBuilder defaultDocument={template} variables={[ // Override a built-in token's sample with this order's real value. { key: 'customer.name', label: 'Customer', sample: order.customer.name }, { key: 'customer.email', label: 'Email', sample: order.customer.email }, { key: 'document.number', label: 'Invoice no.', sample: invoice.number }, { key: 'document.date', label: 'Date', sample: formatDate(invoice.issuedAt) }, // Add a token that only YOUR product has. { key: 'order.trackingCode', label: 'Tracking code', sample: order.tracking, group: 'Shipping' }, ]} />
// BEFORE — no variables passed. // The canvas shows the built-in sample. // AFTER variables={[ { key: 'customer.name', sample: order.customer.name }, { key: 'document.number', sample: invoice.number }, ]}
Canvas · before → after
INVOICE
No. Northwind sample
Bill to Northwind Trading Co.
INVOICE
No. INV-0042
Bill to Priya Sharma
The document did not change between those two pictures. Both still store {{ customer.name }}. Only what the canvas draws in its place changed — which is why the same template serves every customer.

Generating the list from a record is usually cleaner than writing it out:

const variables = [ ['customer.name', 'Customer', order.customer.name], ['customer.email', 'Email', order.customer.email], ['document.number', 'Invoice no.', invoice.number], ].map(([key, label, sample]) => ({ key, label, sample: sample ?? '' })) <PdfBuilder variables={variables} />
FieldMeaning
keyThe token name without the braces — 'customer.name', not '{{ customer.name }}'.
labelWhat the user sees when picking a token.
sampleWhat the canvas draws in its place. Put your real value here.
groupGroups related tokens in the picker.
A variables entry with an existing key overrides the built-in sample. Nothing else is needed — no registration step, no opt-in. If the key matches, your value wins, everywhere that token appears.

Method B — substitute at render time

Keep the tokens in the document and replace them on the way out. This is how you produce thousands of documents from one template with no browser and no user.

import { interpolateContent } from '@storvexa/pdf-builder' // NOTE: a Map, not a plain object. const values = new Map([ ['customer.name', order.customer.name], ['document.number', invoice.number], ['document.date', formatDate(invoice.issuedAt)], ]) // Walk the document and fill every block's content. const filled = { ...template, rows: template.rows.map((row) => ({ ...row, columns: row.columns.map((col) => ({ ...col, blocks: col.blocks.map((b) => ({ ...b, content: interpolateContent(b.content, values) })), })), })), }
interpolateContent takes a Map, not an object. A plain {} silently matches nothing, and every token falls back to its humanised name — so the page renders "Name" instead of the customer's name, with no error to explain it.

It recurses into nested objects and arrays, so list fields — address rows, key/value pairs, table columns — are covered too. And it returns the original object when nothing changed, so renderers keep referential equality.

For a single string, use interpolateText:

import { interpolateText } from '@storvexa/pdf-builder' interpolateText('Thank you, {{customer.name}}', values) // 'Thank you, Priya Sharma'

What happens to a token you did not supply

It renders a humanised version of its own name, not the raw braces. {{ order.trackingCode }} with no value becomes "Tracking code". So a missing binding produces a plausible-looking label rather than an obvious error — check your document before shipping it, and supply '' deliberately when you want a slot to print blank.

Renaming a placeholder — overriding its label

The key is the identity and the label is what humans read. Change the label and every built-in block still fills correctly, because nothing matches on the label.

<PdfBuilder variables={[ // The token stays customer.name — only what the user READS changes. { key: 'customer.name', label: 'Client name' }, // Rename and regroup, without supplying a value at all. { key: 'company.name', label: 'Your practice', group: 'Practice details' }, { key: 'company.taxId', label: 'GSTIN', group: 'Practice details' }, ]} />
This is how you speak your users' language. A law firm says "matter", a clinic says "patient", a builder's merchant says "job". The token stays customer.name so every block keeps working — the picker just stops saying "Customer" at people who have never used that word.
Label, sample and group are independent. Supply label alone to rename it and keep the built-in sample. Supply sample alone to bind data and keep the built-in name. Supply both when you want both. Anything you omit falls back to the built-in.
Do not rename the key to rename the placeholder. { key: 'clientName', label: 'Client name' } creates a new token that no built-in block references — so the blocks still show the old one, unfilled, and you have two placeholders where you wanted one.

Changing the wording a block prints

Two different things get called "the label", and it is worth separating them clearly:

You want to change…Use
The name in the token pickervariables[].label
The caption printed on the page next to a valueThe Content tab — a labelled field's wording and its show/hide switch
The field's name in the inspectorfields={{ 'documentMeta.content.number': { label: 'Ref no.' } }}
Any other interface stringlabels, or a locale pack

Seeing the whole vocabulary

import { PLACEHOLDER, PLACEHOLDER_SAMPLES, token } from '@storvexa/pdf-builder' console.log(PLACEHOLDER.customer.name) // the token string, braces included console.log(PLACEHOLDER_SAMPLES) // every built-in token and its sample console.log(token('order.trackingCode')) // build one of your own
Bind against this vocabulary rather than inventing keys. Built-in blocks already reference these tokens, so supplying customer.name fills every block that mentions it — the address block, the greeting, the footer — without you touching any of them.

The complete override map

Every kind of thing you might want to change, and the one prop that changes it.

To override…Use
A placeholder's valuevariables (in the builder) or interpolateContent (at render time)
What a new block starts withregistry.extend(type, { defaultContent })
What a block looks like by defaultregistry.extend(type, { defaultStyle })
An existing block's content, in codeupdateBlockContent(doc, id, patch)
An existing block's style, in codeupdateBlockOverrides(doc, id, patch)
The document's colours, fonts, spacingtokens on the document
Defaults for every new documentthemes[].tokens
The editor's own appearancetheme / --sb-* properties
Which typefaces users may pickfonts (replaces the list)
A visible stringlabels, or a locale pack
One field's label, or hide/disable itfields
Part of the interfaceui.hide / show / disable
A block's designsregistry.addLayout, or extend(type, { layouts })
The template listtemplates + builtinTemplates={false}
How money is writtentokens.currency*, or formatCurrency
How the PDF is producedonRenderPdf
Where images liveresolveImageUrl + onUploadImage
The pattern behind the table: content is overridden through the document, presentation through the registry and props. If you find yourself reaching into a block's saved style to change how something looks, there is almost always a design or a token that does it in one place instead.

Why tokens are read-only

A field whose value is entirely a token is a data binding, not authored copy. The inspector shows it, explains what will appear there, and does not let anyone type over it.

Because typing over it would bake one document's facts into a reusable template. The token names the record that fills the slot. A designer places and styles it; they do not author its value, because its value belongs to whoever the document is eventually for.

Prose that merely contains a token stays fully editable — "Thank you, {{ customer.name }}" is something you wrote, so you can keep writing it. Only a value that is nothing but a token is locked.

The consequence to plan for. There is no "clear binding" affordance, so a block whose default is a token cannot be given literal text instead. That is right for a product that always binds these from an account — which is the target — but if your users need to type a one-off value there, tell us before you build around it.