Template composition: variables as rendered HTML chunks

Sometimes one template gets too big to live with. Hundreds of <li> 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 <h3>Crypto deposits</h3><ul><li>BTC — fastest…</li>…</ul>. 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

<h3>Crypto deposits</h3>
<ul>
  <li>{Bitcoin|BTC}{fastest|the most popular} option, {confirms in 10–60 min|settles within an hour}.</li>
  <li>{Ethereum|ETH}{smart-contract chain|programmable network}, {2–5 min blocks|fast block times}.</li>
  /# … 8 more crypto items #/
</ul>
<h3>Fiat deposits</h3>
<ul>
  /# … 12 more fiat items, each with editorial notes #/
</ul>
<h3>Deposit and withdrawal limits</h3>
<table>
  /# … 20+ rows #/
</table>

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 spells out the order of resolution; the relevant lines for composition are:

  1. strip comments;
  2. extract #set 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.

[<sep="\n\n">%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 #/
<li>{Bitcoin|BTC}{fastest|the most popular} option, {confirms in 10–60 min|settles within an hour}.</li>

/# spintax.crypto_item.eth #/
<li>Ethereum — {smart-contract chain|programmable network}, {2–5 min blocks|fast block times}.</li>

Level 2 — Section templates

Wrap the list with structure. Use a placeholder variable for the joined items.

/# spintax.section.crypto #/
<h3>{Crypto deposits|Cryptocurrencies accepted}</h3>
<p>{Pick from|We support} the following coins:</p>
<ul>%CryptoItems%</ul>

%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 #/
<h2>{Accepted payment methods|How to pay}</h2>
%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:

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 <tr> 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.

PatternExample
spintax.<entity>_item.<id>spintax.crypto_item.btc
spintax.<entity>_row.<id>spintax.crypto_row.btc (table row)
spintax.section.<key>spintax.section.crypto
spintax.<page-name>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": "<li>…</li>", … }
  • 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.

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 #/
<li>Visa — {3DS-protected|with 3D Secure} debit and credit cards, {instant deposit|immediate confirmation}.</li>

/# spintax.crypto_item.xrp — destination-tag reminder per coin #/
<li>XRP — fast and {cheap|low-fee}, {do not forget the destination tag|destination tag is required}.</li>

/# spintax.payment_item.qiwi — legacy status per method #/
<li>QIWI — {legacy support|now legacy}, {accepted but discouraged|not recommended for new accounts}.</li>

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.

Quasi-IF and its limits

You will see authors trying to express conditionals with the engine's enum syntax:

{%HasCrypto%|%HasFiat%||<p>Payment methods coming soon.</p>}

The hope is "show the fallback when both flags are empty". The reality: 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.

Spintax does not evaluate conditions. The branch picker is a uniform random.

For real conditional behaviour, do it in the assembler:

const HasCrypto = cryptos.length > 0 ? "1" : "";
const HasFiat   = fiats.length   > 0 ? "1" : "";
const PaymentsSummary = (HasCrypto || HasFiat)
  ? ""
  : "<p>Payment methods coming soon.</p>";

// orchestrator references %PaymentsSummary% directly

One line in the assembler. Deterministic. Testable. Fits cleanly into composition.

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.

Common mistakes

Don'tWhyDo 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 enumsEngine picks randomly, not based on values; output is non-deterministic.Compute conditionals in the assembler; pass the result as a variable.
Inline per-id nuances in the orchestrator or sectionLoses the "edit once, propagate everywhere" benefit.Keep nuances in the per-id _item template.
Mix item-level and section-level concerns in one templateRefactoring becomes painful as the page grows.Three clean levels: item, section, orchestrator.
Hardcode sort order in the orchestratorSort changes need page edits across the catalog.Sort in the assembler from a single sort field on each item.
Forget to short-circuit empty sectionsEmpty <h3> with no <ul> below it ships to production.Return "" from the assembler when the item list is empty.
Trust un-resolved %XxxItems% leftovers in the rendered pageMissing 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.
  • Conditionals (if/else) live in the assembler, not in spintax enums.
  • 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 when you start the next article — the workflow gets faster every time.


Continue the series