# Template composition: variables as rendered HTML chunks
Sometimes one template gets too big to live with. Hundreds of `
` items in a payment-methods page, dozens of inline editorial notes, sort-order changes that have to ripple through every variant — at some point one giant nested template becomes the bottleneck instead of the help. The next step is splitting it into a pipeline of small templates, joined by variables that hold already-rendered HTML.
## The mental shift
So far in this series a variable has been a _value_: a brand name, a year, a comma-separated list of features. Plain strings substituted into the template at render time.
The shift in this guide is small and powerful: **a variable's value can be already-resolved HTML**. Not `"Acme Co."` but `Crypto deposits
`. The resolver does not care; it just substitutes.
This unlocks composition. You build the page from a pipeline of small sub-templates, each rendered to a chunk of HTML, then assembled by an orchestrator that is just a few lines long.
### Without composition
```
Crypto deposits
- {Bitcoin|BTC} — {fastest|the most popular} option, {confirms in 10–60 min|settles within an hour}.
- {Ethereum|ETH} — {smart-contract chain|programmable network}, {2–5 min blocks|fast block times}.
/# … 8 more crypto items #/
Fiat deposits
/# … 12 more fiat items, each with editorial notes #/
Deposit and withdrawal limits
```
That is a 200-line monolith. Adding a coin means editing inside one big enum chain. Sort changes are hand work. Per-currency editorial nuances scatter across the file.
### With composition
```
%CryptoSection%
%FiatSection%
%LimitsSection%
```
Three lines. Each variable already holds the fully resolved HTML for its part of the page. The values come from a pipeline that runs _before_ the orchestrator is rendered.
## Why this works — the engine pipeline
The [syntax reference](https://spintax.net/docs/syntax.md) spells out the order of resolution; the relevant lines for composition are:
1. strip comments;
2. extract `#set` / `#def` directives;
3. merge variables;
4. **expand `%var%` references**;
5. resolve enumerations `{a|b|c}`;
6. resolve permutations `[a|b|c]`;
7. post-process.
Variables expand _before_ enum and permutation resolution. By the time enum/perm runs, `%CryptoSection%` has already been replaced with whatever HTML the assembler computed. No special syntax — variable substitution is literally string replacement.
You can even mix layers: an outer permutation can shuffle pre-rendered sections.
```
[%CryptoSection%|%FiatSection%|%LimitsSection%]
```
Each section is fully resolved first, then the permutation reorders the chunks.
## The three-level pipeline
The pattern lives in three layers, each one stage of refinement:
### Level 1 — Item templates (per id)
Smallest reusable unit. One template per data item: per coin, per payment method, per plan tier, per FAQ entry, per product SKU.
```
/# spintax.crypto_item.btc #/
{Bitcoin|BTC} — {fastest|the most popular} option, {confirms in 10–60 min|settles within an hour}.
/# spintax.crypto_item.eth #/
Ethereum — {smart-contract chain|programmable network}, {2–5 min blocks|fast block times}.
```
### Level 2 — Section templates
Wrap the list with structure. Use a placeholder variable for the joined items.
```
/# spintax.section.crypto #/
{Crypto deposits|Cryptocurrencies accepted}
{Pick from|We support} the following coins:
```
`%CryptoItems%` is "every per-id item resolved and joined as a single string". The assembler builds it.
### Level 3 — Orchestrator
The page-level template. References pre-rendered section variables only.
```
/# spintax.payment_options #/
{Accepted payment methods|How to pay}
%CryptoSection%
%FiatSection%
%LimitsSection%
```
That is the whole orchestrator. Editing rules: change a per-coin description? Edit one item template. Add a new currency? Drop a new item template, add the id to the active list. Reorder? Sort field, not template change.
## Walkthrough — a payment-methods page
A merchant accepts BTC, USDT, ETH for crypto and Visa, Mastercard, SEPA for fiat. Three lookups and a handful of templates produce the full page.
Pseudo-code for the assembler that runs _before_ the orchestrator render:
```js
function buildPaymentVars(merchantId, lang) {
// 1. Pull active items, in display order.
const cryptos = db.query("active cryptos for merchant ordered by sort", merchantId);
const fiats = db.query("active fiats for merchant ordered by sort", merchantId);
// 2. Resolve each per-id template, join the chunks.
const cryptoItems = cryptos
.map(c => parser.process(templates.find(`crypto_item.${c.id}`, lang)))
.join("");
const fiatItems = fiats
.map(f => parser.process(templates.find(`payment_item.${f.id}`, lang)))
.join("");
// 3. Resolve each section template with item placeholders.
const cryptoSection = cryptos.length
? parser.process(templates.find("section.crypto", lang), { CryptoItems: cryptoItems })
: "";
const fiatSection = fiats.length
? parser.process(templates.find("section.fiat", lang), { FiatItems: fiatItems })
: "";
// 4. Limits section is similar; LimitsRows are joined chunks.
const limitsSection = (cryptos.length || fiats.length)
? parser.process(templates.find("section.limits", lang), { LimitsRows: buildLimitsRows(cryptos, fiats, lang) })
: "";
// 5. Return the variables the orchestrator references.
return {
CryptoSection: cryptoSection,
FiatSection: fiatSection,
LimitsSection: limitsSection,
HasCrypto: cryptos.length ? "1" : "",
HasFiat: fiats.length ? "1" : "",
};
}
```
The orchestrator render then receives these alongside any normal site/runtime variables and does a final pass.
## Naming conventions
Convention beats freedom here, because the assembler finds templates by id.
| Pattern | Example |
| --- | --- |
| `spintax._item.` | `spintax.crypto_item.btc` |
| `spintax._row.` | `spintax.crypto_row.btc` (table row) |
| `spintax.section.` | `spintax.section.crypto` |
| `spintax.` | `spintax.payment_options` |
Variables follow the same shape:
- `%CryptoItems%`, `%FiatItems%`, `%LimitsRows%` — joined per-id chunks
- `%CryptoSection%`, `%FiatSection%`, `%LimitsSection%` — resolved sections
- `%HasCrypto%`, `%HasFiat%` — markers (`'1'` or `''`)
PascalCase for variables, snake\_case for IDs, ASCII only for both.
## Storage is your problem, not spintax's
The pattern works the same regardless of where sub-templates live:
- database table (`templates` with id + body + lang)
- JSON file: `{ "crypto_item.btc": "…", … }`
- filesystem: `templates/crypto_item/btc.txt`
- CMS field per locale
The engine doesn't need a database. It just substitutes resolved HTML into variable references. The assembler is your code, written in whatever runtime drives your renders. A WordPress plugin, a Cloudflare Worker, a Node script, a Postgres function — same pattern.
**Why not `#include`?** The engine does have an include directive, and for a single shared block it is the shorter path. This pipeline is not built on it, for three reasons: an included template is its own document — it sees runtime variables but never the parent's `#set`/`#def`, so a rolled form cannot be handed down into it; it only works where the host installed a resolver, and two of ours deliberately have none (the playground and the MCP server), where the line simply stays literal; and when the resolver does not know the reference, the line is removed from the output without a word. An assembler keeps resolution, caching and error handling in your own code, where you can see all three. Full behaviour: the [includes section](https://spintax.net/docs/syntax.md#includes) of the syntax reference.
## Per-id editorial nuances
This is the killer feature. Editorial nuances live with the data, not in every page.
```
/# spintax.payment_item.visa — 3DS warning baked in #/
Visa — {3DS-protected|with 3D Secure} debit and credit cards, {instant deposit|immediate confirmation}.
/# spintax.crypto_item.xrp — destination-tag reminder per coin #/
XRP — fast and {cheap|low-fee}, {do not forget the destination tag|destination tag is required}.
/# spintax.payment_item.qiwi — legacy status per method #/
QIWI — {legacy support|now legacy}, {accepted but discouraged|not recommended for new accounts}.
```
Each per-id template captures the nuance once. Three pages, ten pages, a thousand pages — they all inherit the right warnings. Move QIWI to "deprecated" by editing one template; every render flips simultaneously.
Without composition, those nuances would be inline strings duplicated across pages. Audit nightmare and a slow-burn legal risk in regulated industries.
## Conditional fallback
You will see authors trying to express conditionals with the engine's enum syntax:
```
{%HasCrypto%|%HasFiat%||Payment methods coming soon.
}
```
The hope is "show the fallback when both flags are empty". The reality with a plain `{a|b|c|d}` enum: the engine picks one of the four branches at random with equal probability. The output is non-deterministic and includes `"1"` as a possible variant on the page.
Enum branches are a uniform random pick — they never look at variables. For value-driven choice, use the conditional pre-pass:
```
{?!HasCrypto?{?!HasFiat?Payment methods coming soon.
}}
```
Read it as: _if not crypto and not fiat, render the fallback_. The conditional resolves **before** the random branch picker runs, so the output is fully determined by the variables.
Composite logic that conditional spintax does _not_ support stays in the assembler — comparisons, `&&`/`||`, computed values. Pre-compute a guard variable, then gate it with `{?Guard?…}`. The dedicated [Conditional spintax guide](https://spintax.net/docs/conditional-spintax.md) covers the three forms, the truthy table, the two-pass pipeline, and the anti-patterns in detail.
## When NOT to compose
Composition has overhead — three template kinds to maintain, an assembler to wire, a storage layer to organize. The pipeline pays off when:
- you have five or more similar items sharing a structure;
- per-id editorial nuances or sort-order requirements exist;
- multiple pages reuse the same item set;
- editors need to modify items independently.
Skip composition when:
- the page has one to three items total;
- items don't repeat across pages;
- nothing in the structure changes for the next year;
- nobody but you will edit it.
For a one-off about page or a single article, one self-contained template is faster, cleaner, and easier to debug.
### Prompt fragment — instructing the model to compose
When asking a model to refactor a giant template into a composition pipeline, include these rules verbatim:
- _"Identify groups of repeated structure (3+ items with the same shape). Each group is a candidate for an item template."_
- _"Extract per-item editorial nuances into the item templates. Do not duplicate them in the section or orchestrator."_
- _"Wrap each item group in a section template that uses an `%XxxItems%` placeholder."_
- _"Build the orchestrator from section variables only. Keep it under twenty lines."_
- _"Move every conditional out of spintax into the variable assembler. Spintax does not evaluate conditions; do not use enum branches as if-then-else."_
- _"For every item, suggest a stable id (lowercase, snake\_case) and propose where the item template would live (DB row, JSON key, file path)."_
Working through an agent? The whole series is condensed into the machine-readable [spintax-authoring skill](https://spintax.net/.well-known/agent-skills/spintax-authoring/SKILL.md), and every English page has a clean Markdown mirror — append `.md` to its URL.
## Common mistakes
| Don't | Why | Do instead |
| --- | --- | --- |
| Compose a small page (≤3 items, no editorial variance) | Pipeline overhead is more than the saving. | Keep one self-contained template. |
| Encode conditionals in spintax enums | Engine picks randomly, not based on values; output is non-deterministic. | Use `{?VAR?then|else}` for single-variable checks; compute composite logic in the assembler and gate it with `{?Guard?…}`. |
| Inline per-id nuances in the orchestrator or section | Loses the "edit once, propagate everywhere" benefit. | Keep nuances in the per-id `_item` template. |
| Mix item-level and section-level concerns in one template | Refactoring becomes painful as the page grows. | Three clean levels: item, section, orchestrator. |
| Hardcode sort order in the orchestrator | Sort changes need page edits across the catalog. | Sort in the assembler from a single sort field on each item. |
| Forget to short-circuit empty sections | Empty `` with no `` below it ships to production. | Return `""` from the assembler when the item list is empty. |
| Trust un-resolved `%XxxItems%` leftovers in the rendered page | Missing variable means the placeholder survives literally. | QA pass that flags any leftover `%…%` in production HTML. |
## Composition checklist
- Each repeated item group has its own per-id template.
- Each section has a single `_section` template that references item placeholders.
- The orchestrator references only section variables, not item variables.
- Sort order comes from data, not from template content.
- Single-variable conditionals use `{?VAR?…}`; composite logic stays in the assembler. Never enum branches.
- Empty sections produce `""`, not stray markup.
- Per-id editorial nuances are not duplicated in the section or orchestrator.
- Five sample renders read cleanly across "all categories empty", "only crypto", "only fiat", "all categories present", and "single legacy item".
- No leftover `%…%`, `{…}`, or `[…]` in any render.
That is the end of the series for now. You have the mindset, variables, permutations, grammar, and now composition. [Loop back to the mindset](https://spintax.net/docs/authoring-mindset.md) when you start the next article — the workflow gets faster every time.
---
## Continue the series
- [Reverse authoring mindset](https://spintax.net/docs/authoring-mindset.md): Write the text first. Add markup last.
- [Variables & multi-site reuse](https://spintax.net/docs/variables.md): Precedence, reroll gotchas, separator collisions.
- [Permutations in practice](https://spintax.net/docs/permutations.md): Lists, title-style headings, separator rules.
- [Grammar-safe synonymization](https://spintax.net/docs/grammar-safe-spintax.md): Binding rules. English agreement. Russian cases: families, prepositions, gender.
- [Conditional spintax](https://spintax.net/docs/conditional-spintax.md): Value-driven {?VAR?then|else} — the syntax-level alternative to random enum picks.
- [Plural agreement](https://spintax.net/docs/plural-spintax.md): Pick the right noun form by count. RU/UK/BE and SR/HR/BS 3-form, EN-style 2-form.
- [Repairing spintax templates](https://spintax.net/docs/repairing-spintax.md): Detect → one named recipe → re-check. Lint codes, the catalogue, the repair prompt.
- [Spintax by example](https://spintax.net/examples.md): Every technique in the series, applied to one real paragraph, with real renders.
[Open in playground](https://spintax.net/play/) [Back to all guides](https://spintax.net/docs.md)
---
Source: https://spintax.net/docs/template-composition/