Text layersLast updated on
Last updated on
Create dynamic documents with Paradoc templates
In this guide, we'll use Paradoc 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 Paradoc templates to generate documents. You write placeholders
like {{fields.monthlyRent}}, and Paradoc fills them with your data. Everything
inside {{ }} is an artifact expression, the same language
as field conditions and computed values, so Markdown, HTML, plain text, and
DOCX templates follow the same rules as the rest of the artifact.
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 -yYour project structure should look like this:
my-lease-project/
├── layers/
│ └── lease.md # Your Paradoc template
├── src/ # Your code goes here
├── output/ # Generated documents
└── package.jsonInstall
Install the SDK, unified renderer, and filesystem resolver.
npm install @paradoc/sdk @paradoc/render @paradoc/resolversCreate the Template
Create a Paradoc template at layers/lease.md:
# RESIDENTIAL LEASE AGREEMENT
This Lease Agreement is entered into on **{{fields.startDate}}** between:
**LANDLORD:** {{parties.landlord.name}}
**TENANT(S):**
{{#each parties.tenant}}
- {{item.name}}
{{/each}}
---
## 1. PROPERTY
The Landlord agrees to rent to the Tenant the property located at:
{{fields.address}}
**Property Type:** {{fields.propertyType}}
**Bedrooms:** {{fields.bedrooms}}
---
## 2. TERM
The lease term shall be **{{fields.leaseTerm}}**, starting on **{{fields.startDate}}**.
---
## 3. RENT
The Tenant agrees to pay **{{fields.monthlyRent}}** per month, due on the first of each month.
---
## 4. RULES
{{#if fields.petsAllowed}}
Pets are **permitted** with prior written approval.
{{else}}
Pets are **not permitted** on the premises.
{{/if}}
{{#if fields.smokingAllowed}}
Smoking is **permitted** in designated areas only.
{{else}}
Smoking is **not permitted** on the premises.
{{/if}}
---
## 5. SIGNATURES
**LANDLORD:**
Name: {{parties.landlord.name}}
Signature: ___________________________ Date: ____________
**TENANT(S):**
{{#each parties.tenant}}
Name: {{item.name}}
Signature: ___________________________ Date: ____________
{{/each}}Key syntax patterns:
{{fields.fieldName}}— Output a field value, formatted for its type{{parties.role.property}}— Access party data{{annexes.slot.name}}— An attachment's file name;{{annexes.slot}}prints the name and MIME type{{#each parties.tenant}}...{{/each}}— Loop over a list;itemis the current row{{#if condition}}...{{else}}...{{/if}}— Conditional content; the condition is a boolean expression
Define the Form
Create a form that references the template file. The markdown layer is
file-backed, so bind a resolver when you construct the form — every instance
you derive from leaseAgreement later carries the same resolver.
import { p } from '@paradoc/sdk'
import { createFsResolver } from '@paradoc/resolvers/fs'
const resolver = createFsResolver({ root: '.' })
const leaseAgreement = p.form({
name: 'lease-agreement',
title: 'Residential Lease Agreement',
fields: {
address: { type: 'address', label: 'Property Address', required: true },
propertyType: {
type: 'enum',
label: 'Property Type',
enum: [{ value: 'apartment' }, { value: 'house' }, { value: 'condo' }, { value: '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',
partyType: 'person',
min: 1,
max: 1,
signature: { required: true },
},
tenant: {
label: 'Tenant',
partyType: 'person',
min: 1,
max: 4,
signature: { required: true },
},
},
defaultLayer: 'markdown',
layers: {
markdown: {
kind: 'file',
mimeType: 'text/markdown',
path: 'layers/lease.md',
},
},
}, { resolver })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-0', name: 'Alice Chen' },
tenant: [
{ id: 'tenant-0', name: 'Bob Smith' },
{ id: 'tenant-1', name: 'Carol Johnson' },
],
},
})
if (!filled.isValid()) {
console.log('Errors:', filled.validate().errors)
}Render the Document
Render the filled form using the MIME-selected renderer. The resolver bound
to leaseAgreement travels with filled, so the render call doesn't repeat
it:
import { createLayerRenderer } from '@paradoc/render'
import { writeFileSync } from 'node:fs'
const output = await filled.render({
renderer: createLayerRenderer(),
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 Paradoc
template. The same rendering entry point works for HTML, plain text, PDF, and
DOCX layers because selection follows the layer's mimeType.
Template Syntax Reference
Everything inside a marker is an artifact expression. Templates see the same values as field conditions: fields, computed values by name, parties, and, in a checklist, items.
| Pattern | Example | Description |
|---|---|---|
| Field value | {{fields.monthlyRent}} | Output a field value, formatted for its type |
| Nested value | {{fields.address.line1}} | Access nested properties |
| Party data | {{parties.landlord.name}} | Access party information |
| Computed value | {{totalDue}} | Output a defs value by name |
| Expression | {{fields.bedrooms * 2}}, {{sum(fields.lines.amount)}} | Arithmetic, functions, and aggregates |
| Fallback | {{coalesce(fields.nickname, fields.name)}} | The first value that is present |
| Conditional | {{#if fields.petsAllowed}}...{{/if}} | The condition must be boolean |
| If/else | {{#if fields.bedrooms > 2}}...{{else}}...{{/if}} | Conditional with fallback |
| Unless | {{#unless fields.petsAllowed}}...{{/unless}} | Content when the condition is false |
| Loop | {{#each parties.tenant}}{{item.name}}{{/each}} | Iterate over a list; item is the current row |
| Enclosing row | {{parent.name}} | The outer row inside a nested loop |
| Loop position | {{index(item)}}, {{#if first(item)}}, {{#unless last(item)}} | Zero-based position, first row, last row; only inside a loop |
| Raw HTML | {{{fields.notes}}} | Output without HTML escaping; only text/html layers escape values |
| Signing mark | {{signature(parties.landlord, "landlord-sign")}} | Place a signature; inside a party loop, {{signature("tenant-sign")}} |
| Comment | {{!-- comment --}} | Not rendered in output |
A condition such as {{#if fields.notes != null}} must be boolean: compare a text or number value rather than testing it directly.
Replaced syntax
Templates used to have their own helper syntax. It is no longer accepted, and validation names the replacement:
| Old | Now |
|---|---|
{{#if (eq a "x")}}, ne, gt, gte, lt, lte | {{#if a == "x"}}, !=, >, >=, <, <= |
(and a b), (or a b), (not a) | a and b, a or b, not a |
(contains list "x") | "x" in list or contains(list, "x") |
{{default value "n/a"}} | {{coalesce(value, "n/a")}} |
{{#with parties.landlord}}{{name}}{{/with}} | {{parties.landlord.name}} |
{{this}}, {{../name}}, {{@root.name}} | {{item}}, {{parent.name}}, {{fields.name}} |
{{@index}}, {{@first}}, {{@last}} | {{index(item)}}, {{first(item)}}, {{last(item)}} |
{{signature "loc"}} inside {{#with}} | {{signature(parties.role, "loc")}} |