Data sources
A data source is your side of the contract. It tells the designer what fields exist, what type each one is and what to call it on screen. The designer knows nothing else about your domain — no table names, no API, no records. Users pick from the fields you declared, and what they save is a template that references them by path.
import type { DataSource } from "@broadpaper/core";
export const dataSources: DataSource[] = [
{
id: "client",
label: "Client",
schema: {
firstName: { type: "string", label: "First name" },
riskScore: { type: "number", label: "Risk score (1–10)", min: 1, max: 10 },
nextReview: { type: "date", label: "Next review" }
}
}
];The id becomes the root of every expression: client.firstName. The label is what people see in the field picker and in {{ autocomplete.
Field types
| Type | Used for | Formatting pipes |
|---|---|---|
string | Names, references, free text | upper, lower, truncate |
number | Counts, scores, ratios | number, percent |
currency | Money. Carries its own currency code | currency |
percent | Ratios stored as 0.103 for 10.3% | percent |
date | ISO strings or Date | date |
boolean | Flags, used by conditions | — |
image | URLs the renderer may fetch | — |
object | Grouping. Has fields | — |
array | Lists, for repeaters and tables. Has itemSchema | join |
Declaring currency rather than number matters: it is what lets the picker offer "£12,345.67" as a one-click format, and what makes totals in a table format correctly without anyone writing a pipe.
Nested objects and lists
Objects group related fields; arrays are what repeaters and tables iterate.
{
id: "portfolio",
label: "Portfolio",
schema: {
value: { type: "currency", label: "Current value", currency: "GBP" },
holdings: {
type: "array",
label: "Holdings",
itemSchema: {
type: "object",
fields: {
name: { type: "string", label: "Fund" },
value: { type: "currency", label: "Value", currency: "GBP" },
weight: { type: "percent", label: "Weight" }
}
}
}
}
}An array of objects also exposes each field as a projection — portfolio.holdings.value is the list of every holding's value, which is what sum() and avg() take. The picker shows these as "each Value" so it is clear they are a list rather than one number.
Supplying the data
Schemas are design-time; data is render-time. They are separate on purpose, because the person designing the report and the run that produces it are usually not the same event.
<ReportDesigner
dataSources={dataSources}
sampleData={[{ id: "typical", label: "Typical client", data: sampleData }]}
/>sampleData drives Preview and the design canvas. Give the designer more than one set — a typical one and an awkward one — and layout problems surface while someone is still in a position to fix them. See Validation for the checks that run over the sets you supply.
At render time you pass the real thing:
await renderPdfPaginated({ template, registry, dataSources, data, theme });Nothing about a data source is uploaded anywhere. It is a description you hand to a component in your own page, and to a renderer you run yourself.
Missing and partial data
Real data has holes. An expression that resolves to null or undefined renders as empty rather than as the word "undefined", and a repeater over an empty list renders its empty message. To make a whole block disappear when there is nothing to say, use a condition rather than an empty value — it removes the heading too.
Validation reports bindings that no longer match your schema, which is what catches a template written against last quarter's fields. See Migration & versioning for what to do when a schema changes under templates that are already saved.