Multi-page Report
A complete recipe for rendering large nested datasets across multiple pages - sales orders with line items and bin locations - using Hierarchical Table, hierarchyRules, Section, and Spacer together.
What You'll Build
A paginated report that includes:
- A document header with report title, period, and generated timestamp
- A three-level hierarchical table: orders at the top level, line items per order, and bin locations per line item
- Per-level styling (full grid for orders, minimal borders for line items, compact key-value for bin locations)
- Computed totals (total quantity, line total) rolled up from nested arrays onto parent rows
- Automatic page breaks between order groups
This example is the right pattern whenever your data has more than one level of nesting and you need the renderer to handle depth automatically rather than building manual nested tables.
Controls Used
| Control | Role in This Template |
|---|---|
templateSettings | Sets locale, date format, and currency for consistent column formatting |
section | Wraps the report header with a boxed header row |
hierarchical_table | Renders the nested orders/lines/bins structure with per-level styling |
spacer_separator | Creates visual breathing room between the header section and the table |
Payload Shape
{
"templateId": "sales-order-report",
"templateSettings": { },
"theme": { },
"hierarchyRules": { },
"layoutSchema": { },
"reportTitle": "Sales Order Report",
"reportPeriod": "Q1 2026",
"generatedAt": "2026-03-18T09:00:00Z",
"generatedBy": "Finance Team",
"salesOrders": [
{
"orderNumber": "SO-2026-001",
"customer": "Acme Corp",
"orderDate": "2026-01-15",
"status": "Fulfilled",
"lineItems": [
{
"sku": "WIDGET-A",
"description": "Widget Alpha",
"qty": 100,
"unitPrice": 49.99,
"bins": [
{ "binCode": "BIN-A01", "quantity": 60 },
{ "binCode": "BIN-A02", "quantity": 40 }
]
}
]
}
]
}The hierarchical_table node binds to salesOrders via dataSource: "{{salesOrders}}". The renderer discovers lineItems nested inside each order row, and bins nested inside each line item, automatically - no extra binding config is needed for deeper levels.
Step 1 - Template Settings
"templateSettings": {
"locale": "en-US",
"dateFormat": "MMM D, YYYY",
"timezone": "UTC"
}Step 2 - Report Header Section
A boxed section with a light header row provides a consistent document title block. Setting pagination.breakAfter: "never" keeps the header attached to the first data rows.
{
"type": "section",
"props": {
"title": "Sales Order Report",
"titleIcon": "list",
"showHeader": true,
"headerFillColor": "#1a56db",
"layout": "2"
}
}With layout: "2", the left child shows reportTitle and reportPeriod, the right child shows generatedAt (format: datetime) and generatedBy. No layout_group is needed here because both halves belong under the same header.
Step 3 - Spacer
A spacer_separator with lineStyle: "none" creates a clean gap between the header block and the table without drawing a visible line.
{
"type": "spacer_separator",
"props": {
"lineStyle": "none",
"spaceAbove": 0,
"spaceBelow": 12
}
}When to use a spacer vs marginTop
spacer_separator with lineStyle: "none" is useful when you want unconditional whitespace between two sibling nodes. For spacing that should appear before a section only when it is visible, prefer marginTop on the section itself - orphan protection handles the conditional case automatically.
Step 4 - Hierarchical Table Node
The hierarchical_table node is placed in the layout once and bound to the root array. The hierarchyRules object (at the top level of the payload) controls per-level rendering.
{
"type": "hierarchical_table",
"props": {
"title": "Orders",
"titleIcon": "package",
"dataSource": "{{salesOrders}}",
"maxCols": 8
}
}dataSource syntax
Use double-brace syntax: "{{salesOrders}}". This is not a field token - it is a binding expression that the adaptive renderer resolves at runtime to the root array in the payload.
Step 5 - hierarchyRules
hierarchyRules lives at the top level of the payload alongside your data keys - not inside the hierarchical_table node itself.
Level guide for this dataset:
- Level 1 - scalar header fields (orderNumber, customer, orderDate, status)
- Level 2 -
lineItemsarray (sku, description, qty, unitPrice) - Level 3 -
binsarray (binCode, quantity)
"hierarchyRules": {
"fieldVisibility": {
"internalCode": false
},
"levelStyling": {
"2": {
"fontSize": 10,
"borderStyle": "full",
"stripeOpacity": 0.4,
"columnFormats": {
"unitPrice": "currency",
"qty": "number",
"orderDate": "date",
"lineTotal": "currency",
"totalQty": "number"
}
},
"3": {
"fontSize": 9,
"borderStyle": "minimal",
"format": "compact-kv",
"columnFormats": {
"quantity": "number"
}
}
},
"computations": [
{
"level": 2,
"field": "totalQty",
"label": "Total Qty",
"type": "sum",
"source": "bins",
"aggregateField": "quantity",
"format": "number"
},
{
"level": 2,
"field": "lineTotal",
"type": "formula",
"formula": "qty * unitPrice",
"format": "currency"
}
]
}Computations run before rendering
The computations block writes derived fields (totalQty, lineTotal) onto each level-2 row before the table is rendered. Add these field names to columnFormats so the renderer applies the right format string to the computed values.
Level numbering starts at 1
Level 1 is always the scalar header block (non-array fields). The first array you see in the data is level 2. lineItems nested inside each order is therefore level 2, and bins nested inside each line item is level 3.
Complete Payload
{
"templateId": "sales-order-report",
"templateSettings": {
"locale": "en-US",
"dateFormat": "MMM D, YYYY",
"timezone": "UTC"
},
"theme": {
"primary": "#1a56db"
},
"hierarchyRules": {
"fieldVisibility": {
"internalCode": false
},
"levelStyling": {
"2": {
"fontSize": 10,
"borderStyle": "full",
"stripeOpacity": 0.4,
"columnFormats": {
"unitPrice": "currency",
"qty": "number",
"orderDate": "date",
"lineTotal": "currency",
"totalQty": "number"
}
},
"3": {
"fontSize": 9,
"borderStyle": "minimal",
"format": "compact-kv",
"columnFormats": {
"quantity": "number"
}
}
},
"computations": [
{
"level": 2,
"field": "totalQty",
"label": "Total Qty",
"type": "sum",
"source": "bins",
"aggregateField": "quantity",
"format": "number"
},
{
"level": 2,
"field": "lineTotal",
"type": "formula",
"formula": "qty * unitPrice",
"format": "currency"
}
]
},
"reportTitle": "Sales Order Report",
"reportPeriod": "Q1 2026",
"generatedAt": "2026-03-18T09:00:00Z",
"generatedBy": "Finance Team",
"salesOrders": [
{
"orderNumber": "SO-2026-001",
"customer": "Acme Corp",
"orderDate": "2026-01-15",
"status": "Fulfilled",
"lineItems": [
{
"sku": "WIDGET-A",
"description": "Widget Alpha",
"qty": 100,
"unitPrice": 49.99,
"bins": [
{ "binCode": "BIN-A01", "quantity": 60 },
{ "binCode": "BIN-A02", "quantity": 40 }
]
},
{
"sku": "WIDGET-B",
"description": "Widget Beta",
"qty": 50,
"unitPrice": 89.99,
"bins": [
{ "binCode": "BIN-B01", "quantity": 50 }
]
}
]
},
{
"orderNumber": "SO-2026-002",
"customer": "Globex Inc",
"orderDate": "2026-02-03",
"status": "In Progress",
"lineItems": [
{
"sku": "GADGET-X",
"description": "Gadget X Pro",
"qty": 25,
"unitPrice": 199.00,
"bins": [
{ "binCode": "BIN-C05", "quantity": 25 }
]
}
]
}
]
}Design Decisions Summary
| Decision | Why |
|---|---|
hierarchical_table not nested table nodes | Data has three levels of nesting - a single adaptive node handles all depths automatically |
section layout: "2" for header | Single titled header block with two content areas - no independent section titles needed |
spacer_separator with lineStyle: "none" | Explicit whitespace gap between header and table without a visible divider line |
computations for totalQty and lineTotal | Derived values computed from nested arrays before rendering - keeps business logic out of the payload transformation layer |
format: "compact-kv" for level 3 | Bin data has few fields and high row count - compact key-value pairs consume far less vertical space than a full grid |
fieldVisibility.internalCode: false | Suppress internal fields from the rendered output without removing them from the payload |
Next Steps
- Add a
columnManifestto level 2 for explicit column ordering and overflow sub-rows - see Hierarchical Table: Column Manifest - Add
pagination.breakBefore: "always"to thehierarchical_tablenode to force the table onto its own page - Use
grouping.pageBreakPerGroupon a flattablewhen you have one level of data with group breaks - see Table: Grouping
Approval Workflow
A complete recipe for building a multi-stage approval document - request details, a reviewer grid, signature zones, and an appended QR-verified Approval page - using Section, Layout Group, Signature Zones, and Template Settings together.
Signature Document
A complete recipe for building a flexible signing document - variable signature zone configurations with required and optional zones, initials fields, a clean print layout - using Section, Layout Group, and Signature Zones together.