Build your first PDF
Create a PDF using Format's document model one piece at a time.
The easiest way to learn Format is to start with the smallest possible document and add one piece at a time. Each step below introduces one new concept and shows the full document with changed lines highlighted.
By the end you'll have built a multi-page invoice with repeating headers and footers, page numbers, dynamic data, a reusable piece of markup, a one-off cover page, a notes column that paginates on its own, and a Frame that bounds it. That covers every part of the document model that matters day-to-day.
This tutorial assumes you've completed the Quick start, so Format Studio is installed and running.
Scaffold a new document
A Format Studio project keeps each document in its own directory under documents/. Run the Format CLI to scaffold a blank invoice document.
npx format new document invoice --empty -ypnpm dlx format new document invoice --empty -yyarn dlx format new document invoice --empty -ybun x format new document invoice --empty -yThis creates a fresh document directory with an entry file, an empty stylesheet, and an empty data variant.
documents
invoice
assets
data
/* Your styles go here */documents
invoice
/* Your styles go here */documents
invoice
The HTML document needs a one-line index.ts sibling that re-exports the template and pulls in the stylesheet, so Studio can resolve the document like a .tsx or .vue entry.
export { default } from './index.html'
import './styles.css'/* Your styles go here */A one-page document
Every PDF starts with a single Document at its root. Inside it goes at least one Layout: a page design that carries the page dimensions and produces its own run of pages. The simplest Layout holds content directly.
import { Document, Layout } from '@format.dev/react'
import './styles.css'
export default function InvoiceDocument() {
return (
<Document title="Invoice">
<Document title="Invoice #1024">
<Layout width={793.71} height={1122.52}></Layout>
<Layout id="invoice" width={793.71} height={1122.52}>
<p>Invoice for Horizon Studios.</p>
</Layout>
</Document>
)
}<script setup lang="ts">
import { Document, Layout } from '@format.dev/vue'
</script>
<style>
@import './styles.css';
</style>
<template>
<Document title="Invoice">
<Document title="Invoice #1024">
<Layout :width="793.71" :height="1122.52"></Layout>
<Layout id="invoice" :width="793.71" :height="1122.52">
<p>Invoice for Horizon Studios.</p>
</Layout>
</Document>
</template><template data-type="document" data-title="Invoice">
<template data-type="document" data-title="Invoice #1024">
<template data-type="layout" data-width="793.71px" data-height="1122.52px"></template>
<template data-type="layout" data-id="invoice" data-width="793.71px" data-height="1122.52px">
<p>Invoice for Horizon Studios.</p>
</template>
</template>The Layout's width and height declare the page size: 793.71px × 1122.52px is A4 at 96 dpi (for other sizes see Page sizes and dimensions). The id names the Layout so you can add more of them later. The <p> is the page content.
Headers and footers that repeat
We want a header and a footer to appear on every page. Markup that repeats per page is called scaffolding, and the content that breaks across pages lives in a Flow.
A Flow is a region whose content paginates. Anything you place in the Layout outside a Flow is page scaffolding: it reprints on every page. Content inside the Flow streams once and rolls onto new pages as it overflows.
Make two changes from the previous step. First, add a <header> and a <footer> directly in the Layout as scaffolding. Then wrap the existing <p> in a <Flow> so it becomes the page content that paginates.
import {
Document,
Flow,
Layout
} from '@format.dev/react'
import './styles.css'
export default function InvoiceDocument() {
return (
<Document title="Invoice #1024">
<Layout id="invoice" width={793.71} height={1122.52}>
<header>Horizon Studios · Invoice #1024</header>
<p>Invoice for Horizon Studios.</p>
<Flow>
<p>Invoice for Horizon Studios.</p>
</Flow>
<footer>www.horizonstudios.com</footer>
</Layout>
</Document>
)
}<script setup lang="ts">
import {
Document,
Flow,
Layout
} from '@format.dev/vue'
</script>
<style>
@import './styles.css';
</style>
<template>
<Document title="Invoice #1024">
<Layout id="invoice" :width="793.71" :height="1122.52">
<header>Horizon Studios · Invoice #1024</header>
<p>Invoice for Horizon Studios.</p>
<Flow>
<p>Invoice for Horizon Studios.</p>
</Flow>
<footer>www.horizonstudios.com</footer>
</Layout>
</Document>
</template><template data-type="document" data-title="Invoice #1024">
<template data-type="layout" data-id="invoice" data-width="793.71px" data-height="1122.52px">
<header>Horizon Studios · Invoice #1024</header>
<p>Invoice for Horizon Studios.</p>
<template data-type="flow">
<p>Invoice for Horizon Studios.</p>
</template>
<footer>www.horizonstudios.com</footer>
</template>
</template>The <header> and <footer> tags aren't special to Format. Any structural markup works: a <div>, an <aside>, even a fragment of plain text. The Flow itself takes its own children as the content that paginates. That form is called a bare Flow: it has no wrapper, so a Flow of paragraphs renders as the paragraphs themselves, flowing across pages.
Stream a table of line items
A bare Flow streams its children with no wrapper. When you need markup to wrap the streamed items and repeat on every page, you mark the items with a Stream. A Flow that wraps its Stream in surrounding markup is a scaffolded Flow.
Here the Flow holds a <table>. The <thead> above repeats on every page as scaffolding, and the rows inside the <Stream /> are what flows.
import {
Document,
Flow,
Layout,
Stream
} from '@format.dev/react'
import './styles.css'
export default function InvoiceDocument() {
return (
<Document title="Invoice #1024">
<Layout id="invoice" width={793.71} height={1122.52}>
<header>Horizon Studios · Invoice #1024</header>
<Flow>
<p>Invoice for Horizon Studios.</p>
<table>
<thead>
<tr><th>Description</th><th>Amount</th></tr>
</thead>
<tbody>
<Stream>
<tr><td>Design work</td><td>$1,000</td></tr>
<tr><td>Hosting</td><td>$50</td></tr>
<tr><td>Consulting</td><td>$1,000</td></tr>
</Stream>
</tbody>
</table>
</Flow>
<footer>www.horizonstudios.com</footer>
</Layout>
</Document>
)
}<script setup lang="ts">
import {
Document,
Flow,
Layout,
Stream
} from '@format.dev/vue'
</script>
<style>
@import './styles.css';
</style>
<template>
<Document title="Invoice #1024">
<Layout id="invoice" :width="793.71" :height="1122.52">
<header>Horizon Studios · Invoice #1024</header>
<Flow>
<p>Invoice for Horizon Studios.</p>
<table>
<thead>
<tr><th>Description</th><th>Amount</th></tr>
</thead>
<tbody>
<Stream>
<tr><td>Design work</td><td>$1,000</td></tr>
<tr><td>Hosting</td><td>$50</td></tr>
<tr><td>Consulting</td><td>$1,000</td></tr>
</Stream>
</tbody>
</table>
</Flow>
<footer>www.horizonstudios.com</footer>
</Layout>
</Document>
</template><template data-type="document" data-title="Invoice #1024">
<template data-type="layout" data-id="invoice" data-width="793.71px" data-height="1122.52px">
<header>Horizon Studios · Invoice #1024</header>
<template data-type="flow">
<p>Invoice for Horizon Studios.</p>
<table>
<thead>
<tr><th>Description</th><th>Amount</th></tr>
</thead>
<tbody>
<template data-type="stream">
<tr><td>Design work</td><td>$1,000</td></tr>
<tr><td>Hosting</td><td>$50</td></tr>
<tr><td>Consulting</td><td>$1,000</td></tr>
</template>
</tbody>
</table>
</template>
<footer>www.horizonstudios.com</footer>
</template>
</template>A Stream marks where the flowing items land. The markup wrapping it inside the Flow, the <table> and its <thead> here, is scaffolding: it reprints on each page the rows span. You add a Stream only when scaffolding wraps the items; a bare Flow needs none, because its own children are the stream. The Stream can carry any content Format renders: paragraphs of a book, headings, images, table rows, list items.
With the table in place, pagination is automatic. Given 50 line items, Format fills the page, opens a new one with the header, footer, and <thead> all reprinted, and continues until the rows run out. This is the default behavior, called auto pagination. For the full rules on what repeats and what flows, see Pagination.
Bring in dynamic data
Hardcoded line items are fine for a starter, but real documents take their content from somewhere else: a database row, an API response, a JSON file. Studio passes a variant (any JSON file under documents/<name>/data/) to your document function as the data prop. The default variant lives in default.json.
Edit the JSON file with the invoice's id and line items:
{
"id": "1024",
"lineItems": [
{ "name": "Design work", "amount": 1000 },
{ "name": "Hosting", "amount": 50 },
{ "name": "Consulting", "amount": 1000 }
]
}Define a TypeScript interface for the data shape directly in the entry file (React and Vue only; HTML has no types). The entry function takes data as a prop, then uses data.id for the title and header and data.lineItems for the table rows.
import {
Document,
Flow,
Layout,
type RenderProps,
Stream
} from '@format.dev/react'
import './styles.css'
interface Invoice {
id: string
lineItems: LineItem[]
}
interface LineItem {
name: string
amount: number
}
export default function InvoiceDocument() {
export default function InvoiceDocument({ data }: RenderProps<Invoice>) {
return (
<Document title="Invoice #1024">
<Document title={`Invoice #${data.id}`}>
<Layout id="invoice" width={793.71} height={1122.52}>
<header>Horizon Studios · Invoice #1024</header>
<header>Horizon Studios · Invoice #{data.id}</header>
<Flow>
<table>
<thead>
<tr><th>Description</th><th>Amount</th></tr>
</thead>
<tbody>
<Stream>
<tr><td>Design work</td><td>$1,000</td></tr>
<tr><td>Hosting</td><td>$50</td></tr>
<tr><td>Consulting</td><td>$1,000</td></tr>
{data.lineItems.map((item, i) => (
<tr key={i}>
<td>{item.name}</td>
<td>${item.amount.toLocaleString()}</td>
</tr>
))}
</Stream>
</tbody>
</table>
</Flow>
<footer>www.horizonstudios.com</footer>
</Layout>
</Document>
)
}<script setup lang="ts">
import {
Document,
Flow,
Layout,
type RenderProps,
Stream
} from '@format.dev/vue'
interface Invoice {
id: string
lineItems: LineItem[]
}
interface LineItem {
name: string
amount: number
}
const props = defineProps<RenderProps<Invoice>>()
</script>
<style>
@import './styles.css';
</style>
<template>
<Document title="Invoice #1024">
<Document :title="`Invoice #${props.data.id}`">
<Layout id="invoice" :width="793.71" :height="1122.52">
<header>Horizon Studios · Invoice #1024</header>
<header>Horizon Studios · Invoice #{{ props.data.id }}</header>
<Flow>
<table>
<thead>
<tr><th>Description</th><th>Amount</th></tr>
</thead>
<tbody>
<Stream>
<tr><td>Design work</td><td>$1,000</td></tr>
<tr><td>Hosting</td><td>$50</td></tr>
<tr><td>Consulting</td><td>$1,000</td></tr>
<tr v-for="(item, i) in props.data.lineItems" :key="i">
<td>{{ item.name }}</td>
<td>${{ item.amount.toLocaleString() }}</td>
</tr>
</Stream>
</tbody>
</table>
</Flow>
<footer>www.horizonstudios.com</footer>
</Layout>
</Document>
</template>HTML documents render through Eta, which exposes the data variable inline. Types apply to React and Vue only; the HTML tab uses untyped JSON.
<template data-type="document" data-title="Invoice #1024">
<template data-type="document" data-title="Invoice #<%= data.id %>">
<template data-type="layout" data-id="invoice" data-width="793.71px" data-height="1122.52px">
<header>Horizon Studios · Invoice #1024</header>
<header>Horizon Studios · Invoice #<%= data.id %></header>
<template data-type="flow">
<table>
<thead>
<tr><th>Description</th><th>Amount</th></tr>
</thead>
<tbody>
<template data-type="stream">
<tr><td>Design work</td><td>$1,000</td></tr>
<tr><td>Hosting</td><td>$50</td></tr>
<tr><td>Consulting</td><td>$1,000</td></tr>
<% data.lineItems.forEach(item => { %>
<tr>
<td><%= item.name %></td>
<td>$<%= item.amount.toLocaleString() %></td>
</tr>
<% }) %>
</template>
</tbody>
</table>
</template>
<footer>www.horizonstudios.com</footer>
</template>
</template>You can add another [name].json file alongside default.json to switch between data variants from the Studio sidebar. For runtime validation with Zod or Valibot, schemas inferred back into types, and the rest of the data binding story, see Data and variants. For a worked example with billing addresses, currency formatting, and tax calculations, browse the invoice example in packages/examples.
Style the Layout
Each page Format renders is isolated from the others and exposes a host element you target with :host. To declare page-level custom properties or inheritable styles, write them on :host instead of :root.
Open styles.css and add the rules below. The page inset is set once on :host as --page-inset and reused by the header, main, and footer so their content lines up at the same edge. The header and footer halve it vertically so they sit closer to the page edges than the body.
:host {
--page-inset: 1in;
display: flex;
flex-direction: column;
}
header,
footer {
padding: calc(var(--page-inset) / 2) var(--page-inset);
background: #f3f4f6;
}
main {
padding: var(--page-inset);
}
footer {
margin-top: auto;
}Wrap the Flow in a <main> so the body padding applies to a single element. The <main> sits between the header and footer scaffolding and holds the Flow.
import {
Document,
Flow,
Layout,
type RenderProps,
Stream
} from '@format.dev/react'
import './styles.css'
interface Invoice {
id: string
lineItems: LineItem[]
}
interface LineItem {
name: string
amount: number
}
export default function InvoiceDocument({ data }: RenderProps<Invoice>) {
return (
<Document title={`Invoice #${data.id}`}>
<Layout id="invoice" width={793.71} height={1122.52}>
<header>Horizon Studios · Invoice #{data.id}</header>
<main>
<Flow>
<table>
<thead>
<tr><th>Description</th><th>Amount</th></tr>
</thead>
<tbody>
<Stream>
{data.lineItems.map((item, i) => (
<tr key={i}>
<td>{item.name}</td>
<td>${item.amount.toLocaleString()}</td>
</tr>
))}
</Stream>
</tbody>
</table>
</Flow>
</main>
<footer>www.horizonstudios.com</footer>
</Layout>
</Document>
)
}<script setup lang="ts">
import {
Document,
Flow,
Layout,
type RenderProps,
Stream
} from '@format.dev/vue'
interface Invoice {
id: string
lineItems: LineItem[]
}
interface LineItem {
name: string
amount: number
}
const props = defineProps<RenderProps<Invoice>>()
</script>
<style>
@import './styles.css';
</style>
<template>
<Document :title="`Invoice #${props.data.id}`">
<Layout id="invoice" :width="793.71" :height="1122.52">
<header>Horizon Studios · Invoice #{{ props.data.id }}</header>
<main>
<Flow>
<table>
<thead>
<tr><th>Description</th><th>Amount</th></tr>
</thead>
<tbody>
<Stream>
<tr v-for="(item, i) in props.data.lineItems" :key="i">
<td>{{ item.name }}</td>
<td>${{ item.amount.toLocaleString() }}</td>
</tr>
</Stream>
</tbody>
</table>
</Flow>
</main>
<footer>www.horizonstudios.com</footer>
</Layout>
</Document>
</template><template data-type="document" data-title="Invoice #<%= data.id %>">
<template data-type="layout" data-id="invoice" data-width="793.71px" data-height="1122.52px">
<header>Horizon Studios · Invoice #<%= data.id %></header>
<main>
<template data-type="flow">
<table>
<thead>
<tr><th>Description</th><th>Amount</th></tr>
</thead>
<tbody>
<template data-type="stream">
<% data.lineItems.forEach(item => { %>
<tr>
<td><%= item.name %></td>
<td>$<%= item.amount.toLocaleString() %></td>
</tr>
<% }) %>
</template>
</tbody>
</table>
</template>
</main>
<footer>www.horizonstudios.com</footer>
</template>
</template>Add page numbers
Add <PageNumber /> and <PageCount /> into the footer to render the current page number and the total count in a "Page X of Y" pattern.
import {
Document,
Flow,
Layout,
PageCount,
PageNumber,
type RenderProps,
Stream
} from '@format.dev/react'
import './styles.css'
interface Invoice {
id: string
lineItems: LineItem[]
}
interface LineItem {
name: string
amount: number
}
export default function InvoiceDocument({ data }: RenderProps<Invoice>) {
return (
<Document title={`Invoice #${data.id}`}>
<Layout id="invoice" width={793.71} height={1122.52}>
<header>Horizon Studios · Invoice #{data.id}</header>
<main>
<Flow>
<table>
<thead>
<tr><th>Description</th><th>Amount</th></tr>
</thead>
<tbody>
<Stream>
{data.lineItems.map((item, i) => (
<tr key={i}>
<td>{item.name}</td>
<td>${item.amount.toLocaleString()}</td>
</tr>
))}
</Stream>
</tbody>
</table>
</Flow>
</main>
<footer>www.horizonstudios.com</footer>
<footer>www.horizonstudios.com · Page <PageNumber /> of <PageCount /></footer>
</Layout>
</Document>
)
}<script setup lang="ts">
import {
Document,
Flow,
Layout,
PageCount,
PageNumber,
type RenderProps,
Stream
} from '@format.dev/vue'
interface Invoice {
id: string
lineItems: LineItem[]
}
interface LineItem {
name: string
amount: number
}
const props = defineProps<RenderProps<Invoice>>()
</script>
<style>
@import './styles.css';
</style>
<template>
<Document :title="`Invoice #${props.data.id}`">
<Layout id="invoice" :width="793.71" :height="1122.52">
<header>Horizon Studios · Invoice #{{ props.data.id }}</header>
<main>
<Flow>
<table>
<thead>
<tr><th>Description</th><th>Amount</th></tr>
</thead>
<tbody>
<Stream>
<tr v-for="(item, i) in props.data.lineItems" :key="i">
<td>{{ item.name }}</td>
<td>${{ item.amount.toLocaleString() }}</td>
</tr>
</Stream>
</tbody>
</table>
</Flow>
</main>
<footer>www.horizonstudios.com</footer>
<footer>www.horizonstudios.com · Page <PageNumber /> of <PageCount /></footer>
</Layout>
</Document>
</template>Authoring HTML directly, use <span data-type="page-number"> and <span data-type="page-count"> (the same elements the SDK components expand to). Format's base stylesheet renders the values into them.
<template data-type="document" data-title="Invoice #<%= data.id %>">
<template data-type="layout" data-id="invoice" data-width="793.71px" data-height="1122.52px">
<header>Horizon Studios · Invoice #<%= data.id %></header>
<main>
<template data-type="flow">
<table>
<thead>
<tr><th>Description</th><th>Amount</th></tr>
</thead>
<tbody>
<template data-type="stream">
<% data.lineItems.forEach(item => { %>
<tr>
<td><%= item.name %></td>
<td>$<%= item.amount.toLocaleString() %></td>
</tr>
<% }) %>
</template>
</tbody>
</table>
</template>
</main>
<footer>www.horizonstudios.com</footer>
<footer>www.horizonstudios.com · Page <span data-type="page-number"></span> of <span data-type="page-count"></span></footer>
</template>
</template>For different number formats, the counter(page-number, lower-roman) list-style syntax, and the full list of counters, variables, and data attributes Format publishes per page, see Styling pages.
Extract the row into a reusable piece
The Stream maps over data.lineItems inline. To reuse that row markup, or to keep the entry file readable as the row grows, lift it into a plain framework component. A Flow streams whatever its Stream produces, so a regular React or Vue component that returns a <tr> drops straight in.
import {
Document,
Flow,
Layout,
PageCount,
PageNumber,
type RenderProps,
Stream
} from '@format.dev/react'
import './styles.css'
interface Invoice {
id: string
lineItems: LineItem[]
}
interface LineItem {
name: string
amount: number
}
function LineItemRow({ item }: { item: LineItem }) {
return (
<tr>
<td>{item.name}</td>
<td>${item.amount.toLocaleString()}</td>
</tr>
)
}
export default function InvoiceDocument({ data }: RenderProps<Invoice>) {
return (
<Document title={`Invoice #${data.id}`}>
<Layout id="invoice" width={793.71} height={1122.52}>
<header>Horizon Studios · Invoice #{data.id}</header>
<main>
<Flow>
<table>
<thead>
<tr><th>Description</th><th>Amount</th></tr>
</thead>
<tbody>
<Stream>
{data.lineItems.map((item, i) => (
<tr key={i}>
<td>{item.name}</td>
<td>${item.amount.toLocaleString()}</td>
</tr>
))}
{data.lineItems.map((item, i) => (
<LineItemRow key={i} item={item} />
))}
</Stream>
</tbody>
</table>
</Flow>
</main>
<footer>www.horizonstudios.com · Page <PageNumber /> of <PageCount /></footer>
</Layout>
</Document>
)
}Vue keeps the reusable row in its own single-file component and renders it inside the Stream with v-for.
<script setup lang="ts">
defineProps<{ item: { name: string; amount: number } }>()
</script>
<template>
<tr>
<td>{{ item.name }}</td>
<td>${{ item.amount.toLocaleString() }}</td>
</tr>
</template><script setup lang="ts">
import {
Document,
Flow,
Layout,
PageCount,
PageNumber,
type RenderProps,
Stream
} from '@format.dev/vue'
import LineItemRow from './LineItemRow.vue'
interface Invoice {
id: string
lineItems: LineItem[]
}
interface LineItem {
name: string
amount: number
}
const props = defineProps<RenderProps<Invoice>>()
</script>
<style>
@import './styles.css';
</style>
<template>
<Document :title="`Invoice #${props.data.id}`">
<Layout id="invoice" :width="793.71" :height="1122.52">
<header>Horizon Studios · Invoice #{{ props.data.id }}</header>
<main>
<Flow>
<table>
<thead>
<tr><th>Description</th><th>Amount</th></tr>
</thead>
<tbody>
<Stream>
<tr v-for="(item, i) in props.data.lineItems" :key="i">
<td>{{ item.name }}</td>
<td>${{ item.amount.toLocaleString() }}</td>
</tr>
<LineItemRow v-for="(item, i) in props.data.lineItems" :key="i" :item="item" />
</Stream>
</tbody>
</table>
</Flow>
</main>
<footer>www.horizonstudios.com · Page <PageNumber /> of <PageCount /></footer>
</Layout>
</Document>
</template>Eta has no component primitive, so the HTML tab keeps the row markup in the loop. To share it across documents, move the loop body into an Eta partial and include it. The rest of the document is unchanged.
<template data-type="document" data-title="Invoice #<%= data.id %>">
<template data-type="layout" data-id="invoice" data-width="793.71px" data-height="1122.52px">
<header>Horizon Studios · Invoice #<%= data.id %></header>
<main>
<template data-type="flow">
<table>
<thead>
<tr><th>Description</th><th>Amount</th></tr>
</thead>
<tbody>
<template data-type="stream">
<% data.lineItems.forEach(item => { %>
<tr>
<td><%= item.name %></td>
<td>$<%= item.amount.toLocaleString() %></td>
</tr>
<% }) %>
</template>
</tbody>
</table>
</template>
</main>
<footer>www.horizonstudios.com · Page <span data-type="page-number"></span> of <span data-type="page-count"></span></footer>
</template>
</template>The Stream stays content-agnostic. It flows whatever its children produce, so you can mix the extracted row with other items (a heading, a paragraph, a subtotal row) and each renders in document order. Pagination is unchanged: the <thead> still sits in the Flow's scaffolding, so it heads each page of rows.
Add a cover page
A document can hold more than one Layout, and each one produces its own run of pages. Add a cover by writing a second Layout before the invoice one. The cover carries its own dimensions and content directly, with no header, footer, or table.
import {
Document,
Flow,
Layout,
PageCount,
PageNumber,
type RenderProps,
Stream
} from '@format.dev/react'
import './styles.css'
interface Invoice {
id: string
lineItems: LineItem[]
}
interface LineItem {
name: string
amount: number
}
function LineItemRow({ item }: { item: LineItem }) {
return (
<tr>
<td>{item.name}</td>
<td>${item.amount.toLocaleString()}</td>
</tr>
)
}
export default function InvoiceDocument({ data }: RenderProps<Invoice>) {
return (
<Document title={`Invoice #${data.id}`}>
<Layout id="cover" width={793.71} height={1122.52}>
<Flow>
<h1>Invoice #{data.id}</h1>
<p>Horizon Studios · April 2026</p>
</Flow>
</Layout>
<Layout id="invoice" width={793.71} height={1122.52}>
<header>Horizon Studios · Invoice #{data.id}</header>
<main>
<Flow>
<table>
<thead>
<tr><th>Description</th><th>Amount</th></tr>
</thead>
<tbody>
<Stream>
{data.lineItems.map((item, i) => (
<LineItemRow key={i} item={item} />
))}
</Stream>
</tbody>
</table>
</Flow>
</main>
<footer>www.horizonstudios.com · Page <PageNumber /> of <PageCount /></footer>
</Layout>
</Document>
)
}<script setup lang="ts">
import {
Document,
Flow,
Layout,
PageCount,
PageNumber,
type RenderProps,
Stream
} from '@format.dev/vue'
import LineItemRow from './LineItemRow.vue'
interface Invoice {
id: string
lineItems: LineItem[]
}
interface LineItem {
name: string
amount: number
}
const props = defineProps<RenderProps<Invoice>>()
</script>
<style>
@import './styles.css';
</style>
<template>
<Document :title="`Invoice #${props.data.id}`">
<Layout id="cover" :width="793.71" :height="1122.52">
<Flow>
<h1>Invoice #{{ props.data.id }}</h1>
<p>Horizon Studios · April 2026</p>
</Flow>
</Layout>
<Layout id="invoice" :width="793.71" :height="1122.52">
<header>Horizon Studios · Invoice #{{ props.data.id }}</header>
<main>
<Flow>
<table>
<thead>
<tr><th>Description</th><th>Amount</th></tr>
</thead>
<tbody>
<Stream>
<LineItemRow v-for="(item, i) in props.data.lineItems" :key="i" :item="item" />
</Stream>
</tbody>
</table>
</Flow>
</main>
<footer>www.horizonstudios.com · Page <PageNumber /> of <PageCount /></footer>
</Layout>
</Document>
</template><template data-type="document" data-title="Invoice #<%= data.id %>">
<template data-type="layout" data-id="cover" data-width="793.71px" data-height="1122.52px">
<template data-type="flow">
<h1>Invoice #<%= data.id %></h1>
<p>Horizon Studios · April 2026</p>
</template>
</template>
<template data-type="layout" data-id="invoice" data-width="793.71px" data-height="1122.52px">
<header>Horizon Studios · Invoice #<%= data.id %></header>
<main>
<template data-type="flow">
<table>
<thead>
<tr><th>Description</th><th>Amount</th></tr>
</thead>
<tbody>
<template data-type="stream">
<% data.lineItems.forEach(item => { %>
<tr>
<td><%= item.name %></td>
<td>$<%= item.amount.toLocaleString() %></td>
</tr>
<% }) %>
</template>
</tbody>
</table>
</template>
</main>
<footer>www.horizonstudios.com · Page <span data-type="page-number"></span> of <span data-type="page-count"></span></footer>
</template>
</template>Layouts render in document order, and each starts on a fresh page. The cover Layout has no header, footer, or page numbers, so its page stays clean. The invoice Layout that follows carries the full scaffolding. Split your design into separate Layouts whenever the page dimensions or the surrounding markup differ. To instead suppress the header, footer, or page numbers on a single page within one Layout, target that page with :host([data-first-page]) or :host([data-page-number="N"]); see Styling pages.
Add a notes column
A Layout can hold more than one Flow, and each one paginates on its own. Add a notes column to the right of the line items so payment terms and bank details have their own stream. The two Flows fill against the page independently: when either fills the page, it rolls to the next while the other keeps filling.
Three changes: extend the data with a notes array, turn <main> into a two-column grid (with the line-items Flow wrapped in a <div> so it stays in one grid cell), then add a second Flow for the notes.
{
"id": "1024",
"lineItems": [
{ "name": "Design work", "amount": 1000 },
{ "name": "Hosting", "amount": 50 },
{ "name": "Consulting", "amount": 1000 }
],
"notes": [
"Payment is due within 30 days of the invoice date. Late payments may incur a 1.5% monthly interest charge.",
"Bank: Acme Bank · Account 12345678 · Sort code 12-34-56 · SWIFT ACMEGB22.",
"Reference your invoice number when paying so we can match payments to the right invoice.",
"Questions? Email [email protected] or call +44 20 7946 0123, Monday to Friday, 9am to 5pm GMT.",
"Thanks for working with us."
]
}:host {
--page-inset: 1in;
display: flex;
flex-direction: column;
}
header,
footer {
padding: calc(var(--page-inset) / 2) var(--page-inset);
background: #f3f4f6;
}
main {
padding: var(--page-inset);
display: grid;
grid-template-columns: 1fr 6cm;
gap: 1cm;
}
footer {
margin-top: auto;
}import {
Document,
Flow,
Layout,
PageCount,
PageNumber,
type RenderProps,
Stream
} from '@format.dev/react'
import './styles.css'
interface Invoice {
id: string
lineItems: LineItem[]
notes: string[]
}
interface LineItem {
name: string
amount: number
}
function LineItemRow({ item }: { item: LineItem }) {
return (
<tr>
<td>{item.name}</td>
<td>${item.amount.toLocaleString()}</td>
</tr>
)
}
export default function InvoiceDocument({ data }: RenderProps<Invoice>) {
return (
<Document title={`Invoice #${data.id}`}>
<Layout id="cover" width={793.71} height={1122.52}>
<Flow>
<h1>Invoice #{data.id}</h1>
<p>Horizon Studios · April 2026</p>
</Flow>
</Layout>
<Layout id="invoice" width={793.71} height={1122.52}>
<header>Horizon Studios · Invoice #{data.id}</header>
<main>
<div>
<Flow>
<table>
<thead>
<tr><th>Description</th><th>Amount</th></tr>
</thead>
<tbody>
<Stream>
{data.lineItems.map((item, i) => (
<LineItemRow key={i} item={item} />
))}
</Stream>
</tbody>
</table>
</Flow>
</div>
<aside>
<Flow>
<h2>Notes</h2>
<Stream>
{data.notes.map((note, i) => (
<p key={i}>{note}</p>
))}
</Stream>
</Flow>
</aside>
</main>
<footer>www.horizonstudios.com · Page <PageNumber /> of <PageCount /></footer>
</Layout>
</Document>
)
}<script setup lang="ts">
import {
Document,
Flow,
Layout,
PageCount,
PageNumber,
type RenderProps,
Stream
} from '@format.dev/vue'
import LineItemRow from './LineItemRow.vue'
interface Invoice {
id: string
lineItems: LineItem[]
notes: string[]
}
interface LineItem {
name: string
amount: number
}
const props = defineProps<RenderProps<Invoice>>()
</script>
<style>
@import './styles.css';
</style>
<template>
<Document :title="`Invoice #${props.data.id}`">
<Layout id="cover" :width="793.71" :height="1122.52">
<Flow>
<h1>Invoice #{{ props.data.id }}</h1>
<p>Horizon Studios · April 2026</p>
</Flow>
</Layout>
<Layout id="invoice" :width="793.71" :height="1122.52">
<header>Horizon Studios · Invoice #{{ props.data.id }}</header>
<main>
<div>
<Flow>
<table>
<thead>
<tr><th>Description</th><th>Amount</th></tr>
</thead>
<tbody>
<Stream>
<LineItemRow v-for="(item, i) in props.data.lineItems" :key="i" :item="item" />
</Stream>
</tbody>
</table>
</Flow>
</div>
<aside>
<Flow>
<h2>Notes</h2>
<Stream>
<p v-for="(note, i) in props.data.notes" :key="i">{{ note }}</p>
</Stream>
</Flow>
</aside>
</main>
<footer>www.horizonstudios.com · Page <PageNumber /> of <PageCount /></footer>
</Layout>
</Document>
</template><template data-type="document" data-title="Invoice #<%= data.id %>">
<template data-type="layout" data-id="cover" data-width="793.71px" data-height="1122.52px">
<template data-type="flow">
<h1>Invoice #<%= data.id %></h1>
<p>Horizon Studios · April 2026</p>
</template>
</template>
<template data-type="layout" data-id="invoice" data-width="793.71px" data-height="1122.52px">
<header>Horizon Studios · Invoice #<%= data.id %></header>
<main>
<div>
<template data-type="flow">
<table>
<thead>
<tr><th>Description</th><th>Amount</th></tr>
</thead>
<tbody>
<template data-type="stream">
<% data.lineItems.forEach(item => { %>
<tr>
<td><%= item.name %></td>
<td>$<%= item.amount.toLocaleString() %></td>
</tr>
<% }) %>
</template>
</tbody>
</table>
</template>
</div>
<aside>
<template data-type="flow">
<h2>Notes</h2>
<template data-type="stream">
<% data.notes.forEach(note => { %>
<p><%= note %></p>
<% }) %>
</template>
</template>
</aside>
</main>
<footer>www.horizonstudios.com · Page <span data-type="page-number"></span> of <span data-type="page-count"></span></footer>
</template>
</template>Each Flow measures overflow against the page, so the body and notes paginate on their own schedules. A long body can spill to page 2 while the notes finish on page 1, and a long notes column can do the same in reverse.
Bound the notes with a Frame
Right now the notes column inherits the page's full available height: notes only overflow when they exceed what fits between the header and footer. To bound them to a smaller region (say 6cm tall), wrap the column in a Frame. A Flow overflows against the closest ancestor with data-type="frame", or the page when none encloses it, so the notes Flow picks the new Frame up automatically.
Two changes: turn the aside into a Frame, then set its height in CSS.
:host {
--page-inset: 1in;
display: flex;
flex-direction: column;
}
header,
footer {
padding: calc(var(--page-inset) / 2) var(--page-inset);
background: #f3f4f6;
}
main {
padding: var(--page-inset);
display: grid;
grid-template-columns: 1fr 6cm;
gap: 1cm;
}
main aside {
height: 6cm;
}
footer {
margin-top: auto;
}<main>
<div>
<Flow>
<!-- line-items table -->
</Flow>
</div>
<aside>
<aside data-type="frame" data-id="notes-region">
<Flow>
<h2>Notes</h2>
<Stream>
<p v-for="(note, i) in props.data.notes" :key="i">{{ note }}</p>
</Stream>
</Flow>
</aside>
</main><main>
<div>
<template data-type="flow">
<!-- line-items table -->
</template>
</div>
<aside>
<aside data-type="frame" data-id="notes-region">
<template data-type="flow">
<h2>Notes</h2>
<template data-type="stream">
<% data.notes.forEach(note => { %>
<p><%= note %></p>
<% }) %>
</template>
</template>
</aside>
</main>The notes Flow now measures against the notes-region Frame (6cm tall) instead of the page. Once the notes fill that height, they overflow to the next page regardless of how much room the layout has left. The body Flow has no Frame ancestor, so it falls back to the page bounds.
Use a Frame when a Flow's overflow bound should be tighter than the page: a fixed-height aside, a half-page chart region, a sidebar callout. If the Flow can fill the full available space, leave it out and let the page bounds apply.
Where to go next
You've used every part of the model that matters. From here:
- Looking up a type? Each type page lists fields, constraints, and examples. See
Layout,Flow,Stream, and the rest. - Need a vocabulary refresher? The Glossary defines every term used across the docs.
- Doing something the rules don't explain? Pagination, Splitting, and Scaffolding cover the runtime behavior in depth.
- Styling pages with
:host()or runtime CSS? Styling pages covers the page boundary, the base layer, and the per-page CSS patterns. - Got an error? Errors lists every diagnostic, what triggers it, and how to fix it.