Text LayersLast updated on
Last updated on
Create dynamic documents with Handlebars templates
In this guide, we'll use OpenForm to create a residential lease agreement. The lease is a perfect example because it combines field values (rent amount, dates), party information (landlord and tenants), conditionals (smoking allowed?), and loops (multiple tenants) — all common patterns in document generation.
About Text Layers
Text layers use Handlebars templates to generate documents. You write a template with placeholders like {{monthlyRent}} and OpenForm fills them with your data. This works for Markdown, HTML, plain text, and DOCX files.
Set Up Your Project
Create a new project and set up the directory structure:
mkdir my-lease-project && cd my-lease-project
mkdir -p layers src output
npm init -ymkdir my-lease-project && cd my-lease-project
mkdir -p layers src output
pnpm initmkdir my-lease-project && cd my-lease-project
mkdir -p layers src output
yarn init -ymkdir my-lease-project && cd my-lease-project
mkdir -p layers src output
bun init -yYour project structure should look like this:
my-lease-project/
├── layers/
│ └── lease.md # Your Handlebars template
├── src/ # Your code goes here
├── output/ # Generated documents
└── package.jsonInstall
Install the SDK and the filesystem resolver.
npm install @open-form/sdk @open-form/resolverspnpm add @open-form/sdk @open-form/resolversyarn add @open-form/sdk @open-form/resolversbun add @open-form/sdk @open-form/resolversCreate the Template
Create a Handlebars template at layers/lease.md:
# RESIDENTIAL LEASE AGREEMENT
This Lease Agreement is entered into on **{{startDate}}** between:
**LANDLORD:** {{parties.landlord.fullName}}
**TENANT(S):**
{{#each parties.tenant}}
- {{fullName}}
{{/each}}
---
## 1. PROPERTY
The Landlord agrees to rent to the Tenant the property located at:
{{address}}
**Property Type:** {{propertyType}}
**Bedrooms:** {{bedrooms}}
---
## 2. TERM
The lease term shall be **{{leaseTerm}}**, starting on **{{startDate}}**.
---
## 3. RENT
The Tenant agrees to pay **{{monthlyRent}}** per month, due on the first of each month.
---
## 4. RULES
{{#if petsAllowed}}
Pets are **permitted** with prior written approval.
{{else}}
Pets are **not permitted** on the premises.
{{/if}}
{{#if smokingAllowed}}
Smoking is **permitted** in designated areas only.
{{else}}
Smoking is **not permitted** on the premises.
{{/if}}
---
## 5. SIGNATURES
**LANDLORD:**
Name: {{parties.landlord.fullName}}
Signature: ___________________________ Date: ____________
**TENANT(S):**
{{#each parties.tenant}}
Name: {{fullName}}
Signature: ___________________________ Date: ____________
{{/each}}Key syntax patterns:
{{fieldName}}— Output a field value{{parties.role.property}}— Access party data{{#each parties.tenant}}...{{/each}}— Loop over multiple parties{{#if condition}}...{{else}}...{{/if}}— Conditional content
Define the Form
Create a form that references the template file:
import { open } from '@open-form/sdk'
const leaseAgreement = open.form({
name: 'lease-agreement',
title: 'Residential Lease Agreement',
fields: {
address: { type: 'address', label: 'Property Address', required: true },
propertyType: {
type: 'enum',
label: 'Property Type',
enum: ['apartment', 'house', 'condo', 'townhouse'],
required: true,
},
bedrooms: { type: 'number', label: 'Bedrooms', min: 0, max: 10, required: true },
startDate: { type: 'date', label: 'Lease Start Date', required: true },
leaseTerm: { type: 'duration', label: 'Lease Term', required: true },
monthlyRent: { type: 'money', label: 'Monthly Rent', required: true },
petsAllowed: { type: 'boolean', label: 'Pets Allowed', default: false },
smokingAllowed: { type: 'boolean', label: 'Smoking Allowed', default: false },
},
parties: {
landlord: {
label: 'Landlord',
min: 1,
max: 1,
signature: { required: true },
},
tenant: {
label: 'Tenant',
min: 1,
max: 4,
signature: { required: true },
},
},
defaultLayer: 'markdown',
layers: {
markdown: {
kind: 'file',
mimeType: 'text/markdown',
path: 'layers/lease.md',
},
},
})Fill the Form
Create a runtime instance by filling the form with field and party data:
const filled = leaseAgreement.fill({
fields: {
address: {
line1: '123 Oak Street, Unit 4B',
locality: 'San Francisco',
region: 'CA',
postalCode: '94102',
country: 'US',
},
propertyType: 'apartment',
bedrooms: 2,
startDate: '2025-02-01',
leaseTerm: 'P12M', // ISO 8601 duration: 12 months
monthlyRent: { amount: 2800, currency: 'USD' },
petsAllowed: true,
smokingAllowed: false,
},
parties: {
landlord: { id: 'landlord-1', fullName: 'Alice Chen' },
tenant: [
{ id: 'tenant-1', fullName: 'Bob Smith' },
{ id: 'tenant-2', fullName: 'Carol Johnson' },
],
},
})
if (!filled.isValid()) {
console.log('Errors:', filled.validate().errors)
}Render the Document
Render the filled form using the text renderer:
import { textRenderer } from '@open-form/sdk'
import { createFsResolver } from '@open-form/resolvers'
import { writeFileSync } from 'node:fs'
// Point the resolver to your project root where layers/ lives
const resolver = createFsResolver({ root: '.' })
const output = await filled.render({
renderer: textRenderer(),
resolver,
layer: 'markdown',
})
writeFileSync('output/lease-agreement.md', output)The output is a string containing your rendered Markdown document with all placeholders filled in.
You've now created a form that generates lease agreements from a Handlebars template. The same approach works for HTML templates, plain text, and DOCX files — just change the mimeType and file extension.
Template Syntax Reference
| Pattern | Example | Description |
|---|---|---|
| Field value | {{fieldName}} | Output a field value |
| Nested value | {{address.line1}} | Access nested properties |
| Party data | {{parties.landlord.fullName}} | Access party information |
| Loop | {{#each parties.tenant}}...{{/each}} | Iterate over arrays |
| Conditional | {{#if petsAllowed}}...{{/if}} | Conditional content |
| If/else | {{#if x}}...{{else}}...{{/if}} | Conditional with fallback |
| Loop index | {{@index}} | Zero-based index in loops |
| Comment | {{!-- comment --}} | Not rendered in output |