Writing templates
A Formfeed template is a web page that Chromium prints. If you know HTML and CSS, you know most of it already. This page covers what is specific to printing and to Formfeed, in the order you usually need it. The syntax of the three template languages is on Template languages, every setting on the settings reference.
What Chromium receives
Formfeed renders your template with the data, then wraps it in a complete HTML document:
<!doctype html>
<html lang="de-DE">
<!-- from settings.locale -->
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<base href="https://cdn.formfeed.dev/a/<workspace id>/" />
<!-- the file library of your workspace, see Images -->
<title>Invoice 2026-0042</title>
<!-- settings.pdf.metadata.title, rendered with the data -->
<!-- your Head tab / head.html -->
<style>
/* @font-face rules for organisation fonts you use */
</style>
<style>
/* brand kit CSS variables: --brand-color-primary, … */
</style>
<style>
/* the print reset below, with @page from settings */
</style>
<style>
/* compiled Tailwind, when settings.tailwind is on */
</style>
<style>
/* your CSS tab / style.css */
</style>
</head>
<body class="formfeed-body formfeed-pdf">
<!-- formfeed-image for image templates -->
<!-- your rendered template -->
<!-- Chart.js and the fit-text script, only when the document uses them -->
</body>
</html>
The print reset gives you sensible defaults you can override in your CSS:
@page {
size: A4;
margin: 20mm 15mm 20mm 15mm;
} /* PDF only, from settings.paper and settings.margin */
html,
body {
margin: 0;
padding: 0;
}
body {
print-color-adjust: exact;
} /* backgrounds and colours print as designed */
img,
svg {
max-width: 100%;
}
table {
border-collapse: collapse;
break-inside: auto;
}
.page-break,
.new-page {
break-after: page;
}
.avoid-break {
break-inside: avoid;
}
tr {
break-inside: avoid;
} /* table rows never split across pages */
thead {
display: table-header-group;
} /* table headers repeat on every page */
tfoot {
display: table-footer-group;
}
The rules for img, svg and table have no specificity (they sit in :where()), so any rule of yours wins over them; the others are plain rules that your CSS overrides because it comes later. An image template gets no @page; instead html and body take the width and height of settings.image and hide what overflows.
Data
The data object of the request is the template's root scope: { "invoice": { "number": "42" } } makes invoice.number available. Keep the structure of your data close to the structure of the document; the template should decide how things look, your code only what they are.
- Sample data is what the editor preview, autocomplete and
formfeed validateuse. Keep several named data sets per template (one line item, fifty line items, all optional fields missing) to see edge cases before your customers do. The set nameddefaultis stored as the version'ssample_data, the others as itsdata_sets(at most 50); in a project they aredata/default.json,data/<name>.json. An API render never uses sample data; it always uses thedataof the request. - The data schema is a JSON Schema of the expected
data. A version stores one only when you store it (Store as contract in the editor,schema.jsonin a project,data_schemathrough the API). Without one, Formfeed infers a schema from thedefaultdata set whenever it is asked: integrations (Zapier, n8n, Make, the MCP server) read it to offer named fields, andGET /templates/{id}/schemareturns it. Checking a render's data against a schema (validate_data) and the schema check before publishing need a stored one; see Typed template data. - Missing values render as empty text in all three languages, also when a whole object is missing (
{{ customer.address.city }}withoutcustomer). Usedefaultfor deliberate fallbacks:{{ customer.vat_id | default("—") }}in Jinja2,{{ customer.vat_id | default: "—" }}in Liquid,{{default customer.vat_id "—"}}in Handlebars. One exception: thedatehelper prints today's date for a missing,nullor empty value, so wrap an optional date in a condition:{% if invoice.paid_at %}{{ invoice.paid_at | date("P") }}{% endif %}.
The editor and formfeed validate check the template against the data set on screen (or the one --data names) and report:
| Finding | Severity | What to do |
|---|---|---|
| Syntax error, a block that is not closed, an end tag that does not match | error | Fix the markup; the render would fail with template_syntax_error. |
| Unknown filter or helper | error | Check the name in the helper reference. |
"invoice.nubmer" is not present in the sample data | warning | A typo, or a field the data set lacks: add it to the data set. |
A compatibility alias (currency for money) | warning | Rename it; the editor offers the fix. |
Page setup
Paper size, orientation and margins live in the template's settings (the editor's Page setup panel):
{
"paper": { "format": "A4", "landscape": false },
"margin": { "top": "25mm", "right": "18mm", "bottom": "20mm", "left": "18mm" }
}
format accepts A0 to A6, Letter, Legal, Tabloid and Ledger, in any case. For anything else set paper.width and paper.height ("width": "100mm", "height": "150mm" for a shipping label). Lengths in paper and margin take the units mm, cm, in and px, or a bare number of pixels; pt, em or % fail the render. Formfeed turns these settings into the @page rule shown above. If you would rather control the page size from CSS, including different sizes for different pages, set "preferCssPageSize": true and write your own @page rules.
Before it prints, Chromium lays the page out in a window as wide and as tall as the paper (A4 portrait: 794 × 1123 CSS pixels), so a script that measures the window, such as a chart library sizing itself, sees the width of the page.
Page breaks
Chromium paginates automatically. You steer it with CSS:
<section class="cover">…</section>
<div class="page-break"></div>
<!-- or {{ pageBreak() }} -->
<section class="terms avoid-break">…</section>
h2 {
break-after: avoid;
} /* keep a heading with the text below it */
.signature {
break-inside: avoid;
}
Long tables work without extra effort: a row moves to the next page whole (only a row taller than a page is split), and a <thead> repeats at the top of every page. Wrap the table in <table>, <thead> and <tbody> rather than divs to get this for free.
Two things work against you here:
- A
<tfoot>repeats at the foot of every page, just like the header. Put a grand total in a last<tbody>of its own, or give thetfootdisplay: table-row-groupand place it after the<tbody>, and it prints once, at the end. - A table inside
display: inline-blockis not paginated at all: Chromium cuts it at the page edge, through whatever row is there, and the header does not repeat. Use a block wrapper.
tbody tr:last-child {
break-before: avoid;
} /* never leave the last row alone on a page */
.group {
break-inside: avoid;
} /* on a <tbody>: keep a group of rows together */
Headers, footers and page numbers
A running header or footer goes into the Header and Footer tabs in the editor (header.html and footer.html in a project, settings.header.html and settings.footer.html through the API). They are rendered once, with the same template language and the same data as the body, and Chromium stamps the result onto every page of a PDF. Image output has no header or footer.
Chromium fills these classes with the current values on each page:
| Class | Value |
|---|---|
pageNumber | Current page number |
totalPages | Total number of pages |
date | Print date, in a format Chromium chooses |
title | Document title (settings.pdf.metadata.title, rendered with the data) |
For a date in your own format, write it with the date helper instead: {{ "now" | date("dd.MM.yyyy") }}.
<!-- Footer tab -->
<div
style="display: flex; justify-content: space-between; width: 100%; font-size: 8pt; color: #666;"
>
<span>{{ company.name }} · Invoice {{ invoice.number }}</span>
<span
>Page <span class="pageNumber"></span> of <span class="totalPages"></span
></span>
</div>
Headers and footers are printed in a separate context, which has these consequences:
- Your stylesheet does not apply. Style header and footer elements inline, as above. Organisation fonts, the brand kit's CSS variables and compiled Tailwind are inlined for you.
- They sit inside the page margin. Make
margin.top(ormargin.bottom) larger than the header (or footer) itself, or the body text runs underneath it.settings.header.heightsets the box height explicitly; it does not change the margin. - They start small. Formfeed gives them a 10px font size and 10mm side padding; set your own sizes inline. The box spans the whole page width, independent of the page margins;
settings.header.padding(andfooter.padding) replaces the side padding,"0"for designs that run edge to edge. - They load no images. An
<img>with a URL stays empty there, files from your library included. The logos of the brand kit are the exception: Formfeed puts them in as data URIs. For any other picture use a data URI, for example a QR code from theqrcodehelper. - Scripts do not run in them.
Fonts
-
Google Fonts and other web fonts: add the
<link>to the Head tab. The render worker fetches and caches the files and waits for fonts to load before it prints.<linkhref="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap"rel="stylesheet"/> -
Your own font files: upload them in the app under Fonts. The Free plan has none, Starter up to 5 files, Pro and above any number; each weight and style is one file. Refer to the family name in your CSS (
font-family: "Brand Sans"): Formfeed adds the@font-facerule for every uploaded family whose name appears anywhere in the template's CSS, head or markup, including in headers and footers. Organisation fonts reach renders of stored templates; an ad-hochtmlrequest does not get them. -
Brand fonts: the heading and body fonts of the brand kit are available as
var(--brand-font-heading)andvar(--brand-font-body). -
Always name a font explicitly. Chromium's fallback fonts on the render servers are not the ones on your laptop.
Images
-
Use a file from the workspace library (
{{ asset('logo.png') }}), an absolute HTTPS URL or a data URI. A relative path resolves against the library too, because the document's<base href>points there, so<img src="logo.png">finds the file of that name. -
The worker fetches images through a cache and waits for them before printing. It loads
https:only, neverhttp:, and for security it refuses private, loopback and cloud-metadata addresses, so an image onlocalhostor on your internal network does not load. Host it publicly, or embed it as a data URI. An image that is refused or cannot be fetched shows as a broken image; the render does not fail. -
The
imagehelper places an image at a fixed size with a crop mode (cover,contain,fill); give it analtthat says what it shows:{{ image(product.photo, { width: 120, height: 80, fit: "cover", alt:product.name }) }} -
QR codes, barcodes and SEPA payment codes are drawn without network access, as SVG data URIs for the
srcof an<img>:<img src="{{ qrcode(order.url, { size: 120 }) }}" alt="…">. Seeqrcode,barcodeandepcQrin the helper reference.
Upload images and PDFs on the Files page of the app, or with POST /files. A file
is addressed by its name, so a new logo.png replaces the old one for every template that uses it, but
not at once: the CDN keeps a file for up to an hour, and the render worker keeps the images a document
loads for up to a week, so a replaced or deleted image can still appear in rendered documents for that
long. When a change has to show in the next document, upload it under a new name (logo-2026.png) and
change the template to use it; see Caching. The library is public to anyone who
knows the URL, so keep confidential documents out of it.
Tailwind CSS
Set "tailwind": true in the settings (Tailwind CSS utility classes in Page setup) and use utility classes in your markup. Formfeed compiles exactly the classes in the class attributes of the rendered document, header and footer (Tailwind v4 with its default theme), so there is no CDN script and no purge step. Classes that come from your data work, because the template is rendered first. Your own CSS still loads after Tailwind and wins.
<h1 class="text-2xl font-semibold text-slate-900">
Invoice {{ invoice.number }}
</h1>
What to know:
- Tailwind's base styles (Preflight) come with it: headings lose their sizes, lists their bullets, margins are reset. Style those elements with classes or in your CSS.
- The CSS tab is not run through Tailwind, so
@apply,@themeand@configdo nothing there. For a brand colour use an arbitrary value such asbg-[var(--brand-color-primary)]. - A class that a script adds in the page after rendering is not compiled. The editor preview compiles in the browser and shows it anyway, so check such a template with True render.
Charts
The chart helper draws a Chart.js chart. Give it plain data (labels with values, or several series), so your integration sends numbers rather than a Chart.js configuration; see Charts from plain data:
{{ chart({ type: "bar", labels: sales.labels, values: sales.values, width: 480,
height: 240, alt: "Revenue per quarter" }) }}
A full Chart.js configuration in data and options works too and wins over the plain fields:
{{ chart({ type: "bar", data: { labels: months, datasets: [{ label: "Revenue",
data: revenue }] }, width: 480, height: 240 }) }}
The chart is 480 × 240 pixels unless you set width and height. Animations are turned off so the preview and the PDF match. alt says in words what the chart shows; a screen reader reads it in place of the picture.
JavaScript
JavaScript runs in the page as in a browser. Before printing, Formfeed waits until the page has loaded, the network has been idle for half a second and all fonts are loaded; loading may take up to 30 seconds. If a script builds content after that, tell Formfeed what to wait for in the settings:
{ "waitFor": { "selector": "#report-ready", "timeoutMs": 8000 } }
and add that element when your script is done. waitFor.timeoutMs is 5,000 by default and at most 30,000; when the element has not appeared by then, the render fails with render_timeout. waitFor.delayMs adds a fixed pause (at most 10 seconds) as a last resort. Every key is in Waiting for content.
Scripts see the locale and time zone of the settings (navigator.language, Intl, Date), and the window has the size of the paper (see Page setup). Scripts in the header and footer do not run.
Localisation
Three settings drive every date, number and currency the helpers format:
{ "locale": "de-DE", "timezone": "Europe/Berlin", "currency": "EUR" }
{{ 1234.5 | money }} then prints 1.234,50 €, and {{ invoice.date | date("PPP") }} prints 11. September 2026. A render request can override the locale per document with "locale": "fr-FR" (a locale inside the request's settings wins over it). Without settings the locale is en-GB, the time zone Europe/Berlin and the currency EUR.
For text, add translations (the Translations panel, i18n.json in a project) and use t:
{
"en": { "invoice.title": "Invoice {number}" },
"de": { "invoice.title": "Rechnung {number}" }
}
<h1>{{ t("invoice.title", { number: invoice.number }) }}</h1>
In Liquid that is {{ "invoice.title" | t: number: invoice.number }}, in Handlebars {{t "invoice.title" number=invoice.number}}. A {name} in the text is replaced by the value of that name, and by nothing when you pass none.
t looks for the exact locale (de-AT), then the language (de), then en, and finally prints the key itself, so a missing translation is visible instead of blank.
File names
Set the name of the output file per request with filename. It is used as you send it: template expressions in it are not rendered, so build the name in your code.
{
"template": "invoice",
"data": { "invoice": { "number": "2026-0042" } },
"filename": "invoice-2026-0042.pdf"
}
Characters other than letters A–Z and a–z, digits, _, ., -, (, ) and spaces become _ (so Müller becomes M_ller), the name is cut to 150 characters, and the extension of the output is added when the name lacks it. Without filename the file is called document.pdf (or .png, .jpg, .webp).
Image templates
A template of kind image renders a screenshot instead of a PDF: social cards, certificates as PNG, product labels. The viewport comes from the settings:
{ "image": { "width": 1200, "height": 630, "deviceScaleFactor": 2 } }
deviceScaleFactor: 2 produces a 2400 × 1260 pixel file that stays sharp on high-density screens. Request "output": "png", "jpg" or "webp" (the last two with image.quality; WebP at 100 is lossless), or set image.format so the template decides when the request names none. Set image.transparent for a transparent PNG or WebP background (the page itself must have none), image.fullPage to capture the whole scroll height, or image.clipSelector to capture a single element. Every key is in Image output.
Image templates can also be designed on a canvas instead of written: see the visual editor.
Fit text to its box
A title of varying length in a box of fixed size either overflows or leaves the box half empty. Mark the box with data-ff-fit="shrink", and its text gets the largest font size at which it fits, between its CSS size and data-ff-fit-min (in px, half the CSS size when left out):
<div class="title" data-ff-fit="shrink" data-ff-fit-min="36">
<h1>{{ post.title }}</h1>
</div>
.title {
width: 1040px;
height: 320px;
overflow: hidden;
}
.title h1 {
margin: 0;
font-size: 76px;
line-height: 1.1;
}
The box needs a fixed width and height; the text is its first child element (or the box itself when it has none). Text that does not fit even at the minimum keeps the minimum size, and the box's overflow decides what shows. Fitting runs after the fonts have loaded and before the render captures the page, and it works in PDF templates too. The previews fit the same way; Safari may pick a slightly smaller size than the render, which True render shows exactly.
Partials
A partial is a fragment a template includes by name: {% include "footer" %} (Jinja2 and Liquid) or {{> footer}} (Handlebars). There are two kinds:
- Template partials belong to one template version, as described below.
- Shared partials belong to the organisation. They are edited on the Partials page or with
formfeed partials, and every template of the same engine that includes one renders with its current version. Use them for what every document must show the same way, such as a letterhead or the legal footer.
When both exist under one name, the template partial wins; see how a name is resolved. In Jinja2, {% import "macros" as m %} and {% from "macros" import price %} load macros from a partial of either kind the same way.
Template partials live in the project's partials/ folder. formfeed templates push sends the partials a template includes, nested ones and those used in the header or footer included, and they are stored with the version. API renders and the editor's preview therefore resolve them, and formfeed templates pull writes them back into partials/. Partials that formfeed partials pull recorded as shared stay out of the version, because they resolve live.
A template partial belongs to the version that uses it, so publishing a template is complete on its own: editing partials/footer.html changes nothing until the next push, and an older version keeps the partial it was published with. push refuses a template whose include has neither a file nor a shared partial, and formfeed validate reports it. A render that includes a partial nobody has fails with template_runtime_error.
The web editor has no partials tab: it keeps the template partials of the version it opens and renders with them and with the organisation's shared partials. Create a fragment that templates written in the app should include as a shared partial on the Partials page.
Limits
| Limit | Value | When it is exceeded |
|---|---|---|
| Template execution (the template language step) | 5 seconds | render_timeout; Liquid reports template_runtime_error |
| Rendered HTML | 20 MB | output_too_large |
| Nested partials | 8 levels in Handlebars; Jinja2 and Liquid have no level limit | Handlebars: render_timeout. A partial that includes itself fails in every language. |
Ad-hoc html in a request | 5 MB | 400 invalid_request |
Loading the page, waiting for waitFor.selector | 30 seconds each (waitFor.timeoutMs, 5 seconds by default, counts for the selector) | render_timeout |
| Requests for images, fonts and other files | 1,000 per render | Further requests are blocked; the render goes on without them. |
| Pages per PDF | Plan limit: 20 (Free), 200 (Starter), 500 (Pro), 1,000 (Business), 5,000 (Enterprise) | page_limit_exceeded |
| Output file size | Plan limit: 10 MB (Free), 50 MB (Starter), 100 MB (Pro), 250 MB (Business and Enterprise) | output_too_large |
A render that exceeds a limit fails with a problem that says which limit it hit.
Debugging
- Dump the data you actually received:
<pre>{{ invoice | json }}</pre>in Jinja2,<pre>{{ invoice | json | escape }}</pre>in Liquid,<pre>{{json invoice}}</pre>in Handlebars. - Use True render in the editor or
formfeed renderwith a test key to see the exact PDF production creates. Both are free; on the Free plan the file carries a test mark. - A template error names the line and the column. The editor's problems list also names the tab (template, header or footer) and jumps there when you click the message. A problem from the API carries the same position as
location.lineandlocation.column; it does not say which part the line is in.