Guides

Text layers

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 -y

Your project structure should look like this:

my-lease-project/
├── layers/
│   └── lease.md          # Your Paradoc template
├── src/                  # Your code goes here
├── output/               # Generated documents
└── package.json

Install

Install the SDK, unified renderer, and filesystem resolver.

npm install @paradoc/sdk @paradoc/render @paradoc/resolvers

Create 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; item is 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.

PatternExampleDescription
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:

OldNow
{{#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")}}

On this page