Templates
Write the layout once in Typst, store it under a name, then render it with different values every time. The template stays on our side; each render sends only the data.
Why templates
An invoice looks the same every month; only the numbers change. Sending the full Typst source on every request works, but it means your application carries layout code, every change needs a deploy, and an agent generating documents has to reproduce 80 lines of formatting it does not care about.
With a stored template the render request becomes:
POST /v1/render
{ "template": "invoice.typ", "inputs": { "no": "2026-0917", "customer": "Meridian Studio GmbH", "total": "8496.60" } }
Templates live in your account, up to 50 of them, 512 KB each. They can include other templates and reference stored assets such as your logo or a corporate font. Rendering a template costs the same as rendering source: per output page.
Inputs
Values arrive in the document as sys.inputs, a Typst dictionary of strings. Read them with a default so the template still compiles when a field is missing:
#let input(key, default: "") = sys.inputs.at(key, default: default)
#input("customer") // "" if absent
#input("currency", default: "EUR") // "EUR" if absent
#input("total", default: "0").replace(".", ",") // strings, so format as you like
Inputs are strings. For numbers use float(input("total")); for lists or tables send JSON and decode it: #let items = json(bytes(input("items"))). Booleans: compare against "true".
Everything in inputs is passed as-is. Numbers are not localised, dates are not parsed. Do formatting in the template so it lives in one place, or pre-format in your application if you prefer the template to stay dumb. Pick one and stay with it.
Example: invoice
A complete A4 invoice with a line-item table from a JSON input, a logo from stored assets, totals computed in the template.
// templates/invoice.typ
#let input(k, default: "") = sys.inputs.at(k, default: default)
#let items = json(bytes(input("items", default: "[]")))
#let fmt(n) = {
let s = str(calc.round(n, digits: 2))
if not s.contains(".") { s + ".00" } else if s.split(".").at(1).len() == 1 { s + "0" } else { s }
}
#let net = items.map(i => i.qty * i.unit).sum(default: 0)
#let vat = net * 0.19
#set page(paper: "a4", margin: (x: 22mm, y: 20mm))
#set text(font: "Libertinus Serif", size: 10.5pt)
#grid(columns: (1fr, auto), align: (left, right),
[#image("/assets/logo.png", height: 12mm)],
[#text(size: 9pt, fill: luma(90))[Invoice #input("no")\ #input("date")]],
)
#v(14mm)
#input("customer")\
#input("address").replace(", ", "\n")
#v(10mm)
#table(
columns: (1fr, auto, auto, auto),
align: (left, right, right, right),
stroke: (x, y) => if y == 0 { (bottom: 0.6pt) } else { (bottom: 0.3pt + luma(200)) },
inset: (y: 6pt),
[*Item*], [*Qty*], [*Unit*], [*Total*],
..items.map(i => (i.name, str(i.qty), fmt(i.unit), fmt(i.qty * i.unit))).flatten(),
)
#v(4mm)
#align(right)[
#grid(columns: (auto, 28mm), align: (left, right), row-gutter: 4pt,
[Net], [#fmt(net)],
[VAT 19 %], [#fmt(vat)],
[*Due by #input("due")*], [*EUR #fmt(net + vat)*],
)
]
#v(1fr)
#text(size: 8.5pt, fill: luma(90))[#input("footer", default: "Thank you for your business.")]
Store it and render:
curl -X PUT https://api.foliant.dev/v1/templates/invoice.typ \
-H "Authorization: Bearer $KEY" -H "Content-Type: text/plain" \
--data-binary @invoice.typ
curl -X POST https://api.foliant.dev/v1/render \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" -o invoice.pdf -d '{
"template": "invoice.typ",
"inputs": {
"no": "2026-0917", "date": "26 September 2026", "due": "15 October 2026",
"customer": "Meridian Studio GmbH", "address": "Rosenthaler Str. 41, 10178 Berlin",
"items": "[{\"name\":\"Design system audit\",\"qty\":1,\"unit\":2400},{\"name\":\"Component library, phase 1\",\"qty\":32,\"unit\":120}]"
}
}'
Note items is a JSON string inside the JSON body. That is the escape hatch for structured data until inputs accept nested values.
Example: certificate
Landscape A5, one page, PNG output for embedding in an email. Uses datetime.today() so the date is right without an input.
// templates/certificate.typ
#let input(k, default: "") = sys.inputs.at(k, default: default)
#set page(width: 210mm, height: 148mm, margin: 16mm)
#set text(font: "Libertinus Serif", size: 11pt)
#set align(center)
#text(size: 9pt, fill: luma(110), tracking: 0.08em)[CERTIFICATE]
#v(6mm)
#text(size: 24pt, weight: 600)[#input("title", default: "Certificate of Completion")]
#v(2mm)
#line(length: 30%, stroke: 0.6pt)
#v(6mm)
This confirms that
#v(2mm)
#text(size: 18pt, style: "italic")[#input("name")]
#v(2mm)
completed #emph(input("course")) on
#datetime.today().display("[day] [month repr:long] [year]").
#v(1fr)
#grid(columns: (1fr, 1fr), gutter: 12mm,
[#line(length: 100%, stroke: 0.5pt) #text(size: 8pt)[#input("signer1", default: "Instructor")]],
[#line(length: 100%, stroke: 0.5pt) #text(size: 8pt)[#input("signer2", default: "Director")]],
)
Render call
{ "template": "certificate.typ",
"format": "png", "ppi": 3,
"inputs": { "name": "Ada Lovelace",
"course": "Analytical Engines" } }
PNG needs a single page; this template has a fixed page size so it always is. ppi: 3 gives 216 dpi, enough for print or a retina screen.
Example: report with data
Multi-page, reads a CSV asset uploaded separately, so the template stays small and the data can be refreshed without touching layout.
// templates/monthly-report.typ
#let input(k, default: "") = sys.inputs.at(k, default: default)
#let rows = csv("/assets/" + input("data", default: "usage.csv"))
#let header = rows.first()
#let body = rows.slice(1)
#set page(paper: "a4", numbering: "1 / 1", header: [#h(1fr) #text(size: 8pt, fill: luma(110))[#input("title") · #input("period")]])
#set text(font: "Libertinus Serif", size: 10pt)
#set heading(numbering: "1.")
= #input("title")
#input("summary")
= Figures
#table(columns: header.len(), align: (left, ..range(header.len() - 1).map(_ => right)),
..header.map(h => strong(h)),
..body.flatten(),
)
= Notes
#input("notes", default: "No notes for this period.")
# upload the data once per period
curl -X PUT https://api.foliant.dev/v1/assets/usage-2026-09.csv \
-H "Authorization: Bearer $KEY" -H "Content-Type: text/csv" --data-binary @usage.csv
# render
curl -X POST https://api.foliant.dev/v1/render -H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" -o report.pdf \
-d '{"template":"monthly-report.typ","inputs":{"title":"Usage report","period":"September 2026","data":"usage-2026-09.csv","summary":"Renders grew 40 % month over month."}}'
The template builds the asset path from an input. That works because "/assets/" appears as a literal in the source, so the loader knows to fetch from your assets; what follows it can be dynamic.
Logos and fonts
Stored assets are addressed as /assets/<name> (leading slash: root of your virtual project, so the path works from inside included templates too) from any document or template:
#image("/assets/logo.png", height: 12mm)- Fonts: upload
Inter-Regular.ttf,Inter-Bold.ttfas assets, then#set text(font: "Inter"). Typst discovers font files among the loaded assets automatically; you do not reference the file by path. - Data:
csv("/assets/rates.csv"),json("/assets/config.json"),read("/assets/terms.txt").
Upload from the dashboard (Files section), or via the API with raw bytes, base64 JSON, or a URL:
curl -X PUT https://api.foliant.dev/v1/assets/Inter-Regular.ttf -H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" -d '{"url":"https://github.com/rsms/inter/raw/master/docs/font-files/Inter-Regular.ttf"}'
Composing templates
Templates can include other templates. Put shared pieces (letterhead, footer, helper functions) in one file and include it:
// templates/_base.typ
#let input(k, default: "") = sys.inputs.at(k, default: default)
#let letterhead() = grid(columns: (1fr, auto),
image("/assets/logo.png", height: 12mm),
text(size: 8.5pt, fill: luma(90))[Meridian Studio GmbH\ Rosenthaler Str. 41\ 10178 Berlin])
#let money(n) = "EUR " + str(calc.round(n, digits: 2))
// templates/quote.typ
#import "/templates/_base.typ": *
#set page(paper: "a4", margin: 22mm)
#letterhead()
#v(12mm)
= Quote #input("no")
Total: #money(float(input("total", default: "0")))
Use #import "/templates/x.typ": * for functions and variables, #include "/templates/x.typ" to paste content. Includes are resolved recursively, up to 40 stored files per render.
Managing templates
| Task | Dashboard | API | MCP tool |
|---|---|---|---|
| List | Files section | GET /v1/templates | list_templates |
| Read | Edit button | GET /v1/templates/{name} | get_template |
| Create or update | Save template | PUT /v1/templates/{name} | save_template |
| Delete | Delete button | DELETE /v1/templates/{name} | not exposed |
| Test without rendering | not yet | POST /v1/render with check: true | check_source |
| Render | not from the dashboard | POST /v1/render with template | render_document |
Template names end in .typ (assets must not): letters, digits, -, _, .. Saving over an existing name replaces it immediately; there is no draft state.
Versioning
There is no built-in version history. Two patterns work well:
- Name by version.
invoice-v3.typ. Your application pins the version it was tested against; you can roll outv4and switch when ready. Old versions stay until you delete them. - Keep templates in your repository. Treat the stored copy as a deploy target: a CI step does
PUT /v1/templates/…for every file undertemplates/on merge. Git is the history.
Either way, check: true against the new template with realistic inputs before switching production traffic. Checks are free.
Pitfalls
- Missing input, hard failure.
sys.inputs.customererrors when absent. Always use.at(key, default: …). - Strings everywhere.
input("qty") * 2is a type error. Convert:int(input("qty")),float(…). - Dynamic asset names without the literal prefix.
image(input("logo"))withlogo: "/assets/x.png"will not load, because the loader scans source text for"/assets/. Writeimage("/assets/" + input("logo"))instead. A reference to a stored file that does not exist fails fast with404 stored_file_not_found, listing what your account does have. - Page count and PNG. A template that can grow past one page cannot use
format: png. Use PDF, or fix the page height. - Fonts by family name. Typst matches on the family inside the font file, not the file name. If
#set text(font: "Inter")falls back to the default, the uploaded file may have a different internal name; check with a font inspector or try"Inter Variable". - Package imports.
@preview/…is not available. Copy the functions you need into a_base.typtemplate.