Component templates
A component template describes a document as a tree of JSON components (containers, text, images, repeated lists and tables), each bound to a value in your data. Nothing in it runs: text is never read as a template expression, and data values are always inserted as text.
Choose components over Handlebars templates when a program generates
the document, or when you want no code in the template at all. Scripts and helpers.js only run
in Handlebars templates.
Build an order confirmation
Section titled “Build an order confirmation”You build a heading, a table of items and a “Paid” badge, then preview, publish and render it.
You need the API_KEY and PDF_URL variables from the quickstart and an
API key with the template:write, template:read, template:publish,
render:create and render:read scopes. Studio and the client libraries cannot start a component template, so
you create it with curl.
1. Write the composition
Section titled “1. Write the composition”Save your data as order.json:
{ "order": { "number": "A-1042", "paid": true }, "lines": [ { "name": "Desk lamp", "price": 39.9 }, { "name": "Bulb, 2 pack", "price": 6.5 } ]}The composition is the template’s body. A root container holds a text bound to the order
number, a table with one row per line, and a badge shown only when the order is paid:
{ "root": { "type": "container", "style": { "display": "flex", "flexDirection": "column", "gap": 16, "padding": 24 }, "children": [ { "type": "text", "text": { "path": ["order", "number"], "prefix": "Order " }, "style": { "fontSize": 20, "fontWeight": 700 } }, { "type": "table", "source": { "path": ["lines"] }, "columns": [ { "id": "name", "header": "Item", "value": { "path": ["name"] } }, { "id": "price", "header": "Price", "value": { "path": ["price"], "format": { "type": "currency", "currency": "EUR" } } } ] }, { "type": "text", "text": "Paid", "visibleWhen": { "path": ["order", "paid"], "operator": "truthy" } } ] }}Inside the table, paths are relative to each line, so ["name"] is the line’s name.
2. Create the template
Section titled “2. Create the template”Send the composition in the draft’s content, next to its page settings (the same fields as
page.json):
curl $PDF_URL/v1/templates \ -H "x-api-key: $API_KEY" \ -H "content-type: application/json" \ -d '{ "name": "Order confirmation", "content": { "composition": { "root": { ... } }, "settings": { "pageSize": { "widthMm": 210, "heightMm": 297 }, "margins": { "topMm": 10, "rightMm": 10, "bottomMm": 10, "leftMm": 10 } } } }'Replace { ... } with the root from step 1. The response is the new template with
draftRevision 1. Save its id:
export TEMPLATE_ID='paste-the-id-here'3. Preview it
Section titled “3. Preview it”curl $PDF_URL/v1/templates/$TEMPLATE_ID/preview \ -H "x-api-key: $API_KEY" \ -H "content-type: application/json" \ -d "{ \"data\": $(cat order.json) }" \ --fail-with-body -o preview.pdfpreview.pdf shows “Order A-1042”, a two-row table with prices such as “€39.90”, and the
“Paid” badge. Preview again with "paid": false and the badge disappears.
The template also opens in Studio, where you edit composition.json. To preview there, add
order.json as a workspace file and pick it in the Data selector
above the preview.
4. Publish
Section titled “4. Publish”Renders by name use the newest published version:
curl $PDF_URL/v1/templates/$TEMPLATE_ID/versions \ -H "x-api-key: $API_KEY" \ -H "content-type: application/json" \ -d '{ "expectedRevision": 1 }'The client cannot publish templates. Open Order confirmation in Studio and click Publish in the header. The status changes to Published v1.
The client cannot publish templates. Open Order confirmation in Studio and click Publish in the header. The status changes to Published v1.
The client cannot publish templates. Open Order confirmation in Studio and click Publish in the header. The status changes to Published v1.
The client cannot publish templates. Open Order confirmation in Studio and click Publish in the header. The status changes to Published v1.
5. Render
Section titled “5. Render”Render the template by name with the Reports API and save order.pdf:
curl "$PDF_URL/v1/reports/render" \ -H "x-api-key: $API_KEY" \ -H "content-type: application/json" \ --fail-with-body -o order.pdf \ -d "{ \"format\": \"pdf\", \"template\": { \"name\": \"Order confirmation\" }, \"data\": $(cat order.json) }"using Typefold.Client;
var client = new TypefoldClient( new Uri(Environment.GetEnvironmentVariable("PDF_URL")!), Environment.GetEnvironmentVariable("API_KEY")!);
byte[] pdf = await client.RenderReportAsync(new RenderReportRequest{ Template = TemplateRef.ByName("Order confirmation"), Data = new { order = new { number = "A-1042", paid = true }, lines = new[] { new { name = "Desk lamp", price = 39.9 }, new { name = "Bulb, 2 pack", price = 6.5 }, }, },});
await File.WriteAllBytesAsync("order.pdf", pdf);import { writeFile } from "node:fs/promises";import { TypefoldClient, TemplateRef } from "@typefold/client";
const client = new TypefoldClient({ baseUrl: process.env.PDF_URL!, apiKey: process.env.API_KEY!,});
const pdf = await client.renderReport({ template: TemplateRef.byName("Order confirmation"), data: { order: { number: "A-1042", paid: true }, lines: [ { name: "Desk lamp", price: 39.9 }, { name: "Bulb, 2 pack", price: 6.5 }, ], },});
await writeFile("order.pdf", pdf);import { writeFile } from "node:fs/promises";import { TypefoldClient, TemplateRef } from "@typefold/client";
const client = new TypefoldClient({ baseUrl: process.env.PDF_URL, apiKey: process.env.API_KEY,});
const pdf = await client.renderReport({ template: TemplateRef.byName("Order confirmation"), data: { order: { number: "A-1042", paid: true }, lines: [ { name: "Desk lamp", price: 39.9 }, { name: "Bulb, 2 pack", price: 6.5 }, ], },});
await writeFile("order.pdf", pdf);import osfrom pathlib import Path
from typefold import TypefoldClient, RenderReportRequest, TemplateRef
client = TypefoldClient(os.environ["PDF_URL"], os.environ["API_KEY"])
pdf = client.render_report( RenderReportRequest( template=TemplateRef.by_name("Order confirmation"), data={ "order": {"number": "A-1042", "paid": True}, "lines": [ {"name": "Desk lamp", "price": 39.9}, {"name": "Bulb, 2 pack", "price": 6.5}, ], }, ))
Path("order.pdf").write_bytes(pdf)To render a specific version in the background, use the Renders API.
Composition
Section titled “Composition”| Field | Type | Description |
|---|---|---|
root |
object | Required. The top component, usually a container. |
components |
object | Up to 100 reusable components by ID. |
stylesheets |
string[] | Up to 20 workspace stylesheet paths from the template’s folder, applied in order. |
header |
string | Handlebars page header, up to 100,000 characters. See headers and footers. |
footer |
string | Handlebars page footer, up to 100,000 characters. |
In Studio, each part has its own file:
composition.jsonholds onlyroot.- Page header and Page footer hold
headerandfooter. - Template files above the preview lists the folder’s stylesheets; tick the ones to apply.
- Studio does not show
components. Saving there keeps them unchanged; change them through the API (PUT /v1/templates/{id}/draft).
Components
Section titled “Components”Every component has a type and these optional fields:
| Field | Type | Description |
|---|---|---|
style |
object | CSS properties in camelCase. See styling. |
className |
string | Up to 1,000 characters of space-separated classes from the template’s stylesheets. |
visibleWhen |
object | A condition. When it is false, the component and its children are left out. |
container
Section titled “container”Groups other components. Lay them out with CSS in style.
| Field | Type | Description |
|---|---|---|
children |
array | Required. Up to 1,000 child components. |
| Field | Type | Description |
|---|---|---|
text |
value | Required. A literal, a binding, or mixed text. |
| Field | Type | Description |
|---|---|---|
assetId |
string or binding | Required. The ID of an uploaded image, or a binding that selects one. |
width |
number | Width in CSS pixels, above 0. |
height |
number | Height in CSS pixels, above 0. |
To get an ID, add the image to the workspace in Studio and open it: the ID is shown as
Asset ID. Or upload it with POST /v1/assets (see the API reference).
Publishing copies the image into the version, so deleting it later does not change published
versions.
A binding in assetId can only select an image the template already names literally, for
example in another image component. Anything else fails with 400 ASSET_INVALID. To pick one
of several logos, add one image per logo and show the right one with visibleWhen and equals.
repeat
Section titled “repeat”Renders its children once for each item of an array. Inside, bindings read from the current item.
| Field | Type | Description |
|---|---|---|
source |
data source | Required. The path of the array. |
children |
array | Required. Up to 1,000 components rendered for each item. |
empty |
array | Up to 1,000 components rendered instead when the array has no items. |
The repeat is a flex column, and each item gets its own container. A source that is missing or
null counts as empty; any other value that is not an array fails with 400 BAD_REQUEST.
Renders a header row and one row per item of an array.
| Field | Type | Default | Description |
|---|---|---|---|
source |
data source | Required. The path of the array; each item becomes a row. | |
columns |
array | Required. 1 to 30 columns, described below. | |
showHeader |
boolean | true |
false leaves out the header row. |
headerStyle |
object | Style for header cells, applied last. | |
cellStyle |
object | Style for every cell. | |
emptyText |
string | Up to 10,000 characters shown in one full-width row when the array is empty. |
Each column has:
| Field | Type | Description |
|---|---|---|
id |
string | Required. Unique in the table. Up to 128 letters, digits, _ and -, starting with a letter or digit. |
header |
string | Required. Header text, up to 1,000 characters. Can be empty. |
value |
value | Required. What the cell shows. Bindings read from the row’s item. |
width |
number | Width in CSS pixels, above 0 and up to 2,000. Without it, the table sizes the column. |
style |
object | Style for this column’s cells. |
Default look and behaviour:
- The table spans the full width with collapsed borders.
- Cells have 6px padding, a 1px
#d1d5dbborder and left-aligned text. Header cells are semibold on#f3f4f6. cellStyleapplies first, then the column’sstyle, thenheaderStyleon header cells.- A row is never split across a page break.
- Without
emptyText, an empty array leaves only the header row.
Set column widths with width, not a percentage in style: percentage cell widths can clip the
contents.
component
Section titled “component”Renders a reusable component.
| Field | Type | Description |
|---|---|---|
componentId |
string | Required. The key of the reusable component. |
data |
data source | Where the component reads its data. Without it, it reads the caller’s data. |
Reusable components
Section titled “Reusable components”Define a block once under components, next to root, and use it anywhere with a component.
The key is its ID: up to 128 letters, digits, _ and -, starting with a letter or digit. Each
has a name (1 to 200 characters) and a root component.
{ "root": { "type": "component", "componentId": "address", "data": { "path": ["billing"] } }, "components": { "address": { "name": "Postal address", "root": { "type": "container", "children": [ { "type": "text", "text": { "path": ["name"] } }, { "type": "text", "text": { "path": ["street"] } }, { "type": "text", "text": { "path": ["city"] } } ] } } }}Reusable components can use each other, but not themselves, directly or indirectly. An unknown
componentId, a circular reference, or data pointing at a missing value fails the preview or
render with 400 BAD_REQUEST.
Values and bindings
Section titled “Values and bindings”text, a column’s value and an image’s assetId take a value:
- A literal string (up to 100,000 characters), number or boolean:
"Invoice",42,true. - A binding to a path in the data:
{ "path": ["customer", "name"] }. - Mixed text joining literals and bindings.
Bindings
Section titled “Bindings”| Field | Type | Default | Description |
|---|---|---|---|
path |
string[] | Required. 1 to 20 property names, each up to 128 characters. | |
scope |
string | current |
current reads from the current repeat or table item, root from the whole data. |
fallback |
string, number or boolean | Shown when the path is missing or not a plain value. | |
format |
object | How to format a number or date. | |
prefix |
string | Text before the value, up to 1,000 characters. | |
suffix |
string | Text after the value, up to 1,000 characters. |
Array items use their index as a string, such as ["lines", "0", "name"]. The names
__proto__, constructor and prototype are not allowed.
A data source (source in repeat and table, data in component) takes only path and
scope.
Outside any repeat or table, the current item is the whole data. Inside one, use
"scope": "root" to reach data outside the item:
{ "id": "currency", "header": "Currency", "value": { "path": ["invoice", "currency"], "scope": "root" }}Missing values
Section titled “Missing values”A binding must find a string, number or boolean. If the value is missing, null, an object or an
array, the preview or render fails with 400 BAD_REQUEST naming the path. Give every optional
value a fallback, even "":
{ "path": ["order", "note"], "fallback": "" }The fallback is shown as written, without format, but with prefix and suffix.
Mixed text
Section titled “Mixed text”parts joins 1 to 100 literals and bindings into one string of up to 1,000,000 characters:
{ "type": "text", "text": { "parts": ["Dear ", { "path": ["customer", "firstName"] }, ", your order has shipped."] }}Formats
Section titled “Formats”A binding’s format turns a number or date into text for a locale:
| Field | Type | Default | Description |
|---|---|---|---|
type |
string | Required. number, currency, percent or date. |
|
locale |
string | see below | A locale such as de-CH, 2 to 64 characters. |
currency |
string | A three-letter ISO 4217 code such as EUR. Required for currency. |
|
minimumFractionDigits |
integer | 0 to 20. For number, currency and percent. |
|
maximumFractionDigits |
integer | 0 to 20. For number, currency and percent. |
|
dateStyle |
string | medium |
short, medium, long or full. For date. |
timeZone |
string | UTC |
An IANA time zone such as Europe/Berlin. For date. |
| Type | Value | Output (en-GB) |
|---|---|---|
number |
A number | 1,234.5 |
currency |
A number | €1,234.50 |
percent |
A number, where 0.2 is 20% |
20% |
date |
An ISO date (2026-10-08), an ISO timestamp with a zone (2026-10-08T07:30:00Z), or milliseconds since 1970 |
8 Oct 2026 |
Numbers must be JSON numbers, not strings such as "12.50". A value of the wrong type fails with
400 BAD_REQUEST.
Without locale, the format uses the render’s language: the request’s locale for the
Reports API (en-US when left out) and for a preview that sends one,
otherwise the locale in page.json. The Renders API always uses page.json.
Conditions
Section titled “Conditions”visibleWhen shows a component only when a condition about the data holds:
{ "type": "text", "text": "PAID", "visibleWhen": { "path": ["invoice", "status"], "operator": "equals", "value": "paid" }}| Operator | Shows the component when the value is |
|---|---|
truthy |
Present and not false, 0, "" or null. |
falsy |
Missing, false, 0, "" or null. |
exists |
Present and not null. |
notExists |
Missing or null. |
equals |
Exactly equal to value, same type and content. |
notEquals |
Not exactly equal to value. |
Conditions take path and scope like a binding. equals and notEquals need a value (a
string, number or boolean); without one, the component is hidden. A missing path never fails a
condition.
Styling
Section titled “Styling”style takes CSS properties in camelCase. Numbers are CSS pixels; anything else is a string:
{ "style": { "display": "flex", "gap": 12, "padding": 8, "borderWidth": 1, "borderStyle": "solid", "borderColor": "#e5e7eb" }}Styles are static and cannot be bound to data. To change the look with the data, show
alternative components with visibleWhen.
For shared styles, apply workspace stylesheets (stylesheets, or Template files in Studio)
and refer to their classes with className. Stylesheets can load workspace fonts and images.
See supported CSS.
Headers, footers and translations
Section titled “Headers, footers and translations”header and footer are Handlebars, drawn in the page margins with the same data and page
numbers as in any template. See build a template.
Translation files work as in Handlebars templates: the file for the
render’s language is added to the data as localization, unless the data already has one. Bind
its labels like any value:
{ "type": "text", "text": { "path": ["localization", "invoiceTitle"] } }Limits
Section titled “Limits”Field limits are in the tables above. Limits on the whole composition, such as the number of
components, nesting depth and items in one repeat or table, are in limits.
Limits checked when you save the draft fail with 400 BAD_REQUEST; limits checked when a preview
or render fills in the data fail with 400 LIMIT_EXCEEDED.
Render a tree without a template
Section titled “Render a tree without a template”To render a tree you build in code without keeping a template, send a node document to
POST /v1/renders. It has its own shape, with no bindings; see
render documents.