Template-driven documents vs custom HTML: when to use each
If you're generating PDFs programmatically, you'll eventually hit the same fork in the road: do you build a templating system, or do you generate raw HTML and convert it to PDF? Both approaches work. Both have failure modes. The right choice depends on who writes the documents, how often they change, and how much variance you need per render.
This post is about making that decision deliberately.
What "template-driven" actually means
A template-driven workflow separates two things:
- The template — a reusable layout, often written in a language like Handlebars, Liquid, or MJML, stored as a file or in a database.
- The data — a JSON payload that gets injected into the template at render time.
The call site looks like this:
tsimport { kamy } from "@kamydev/sdk"; const pdf = await kamy.documents.render({ template: "invoice", data: { invoiceNumber: "INV-2041", customer: { name: "Acme Co.", email: "[email protected]" }, lineItems: [ { description: "Pro plan", qty: 1, amount: 4900 }, { description: "Add-on seats", qty: 3, amount: 1500 }, ], total: 9400, }, });
The template is fixed. The data changes per invoice. Anyone — including a non-engineer — can tweak the template without redeploying code.
What "custom HTML" actually means
The custom HTML approach means your application code builds the full HTML string (or DOM) for every render, then ships it to a PDF engine:
tsconst html = ` <!doctype html> <html> <body> <h1>Invoice ${invoice.number}</h1> <table> ${invoice.lineItems .map( (li) => ` <tr> <td>${li.description}</td> <td>${li.qty}</td> <td>$${(li.amount / 100).toFixed(2)}</td> </tr>` ) .join("")} </table> </body> </html> `; const pdf = await kamy.documents.renderHtml({ html });
You have total control. You also own every edge case — escaping, layout, page breaks, fonts, the works.
When templates are the right call
Reach for a template system when the structure is stable and the data is the variable.
Typical fits:
- Invoices, receipts, and statements. The layout doesn't change between customers. Only the numbers do.
- Contracts and legal documents with clause libraries and merge fields.
- Reports with predictable sections (summary, table, chart, footer).
- Any document a non-developer should be able to edit. Marketing wants to change copy on the receipt? They shouldn't need a PR.
Templates also win on:
- Caching. Compiled templates are reused across millions of renders.
- Versioning. You can roll forward and back without code deploys.
- Auditability. "What did the December invoice template look like?" becomes a question with a clean answer.
A typical project layout:
txttemplates/ invoice/ v1.hbs v2.hbs receipt/ v1.hbs contract-msa/ v1.hbs
Each render references a specific template + version. Data is validated against a schema before being passed in.
When custom HTML is the right call
Reach for raw HTML when the document structure itself depends on runtime data, or when the document is essentially one-of-a-kind.
Good fits:
- Generated reports where sections appear or disappear based on the dataset (e.g., a security audit where you only render findings that exist).
- Documents driven by user-built layouts — think a form builder that lets users design their own certificate.
- Highly dynamic visualizations where you're injecting SVG or canvas output that's computed per render.
- One-off internal tools where building a templating layer is pure overhead.
The cost is that you now own the rendering pipeline. Common gotchas:
- Forgetting to escape user input, which then renders as HTML inside the PDF.
- Page-break behavior that only shows up at certain row counts.
- Font loading races when you reference remote fonts.
If you do go this route, sanitize aggressively:
tsimport { escape } from "lodash"; const safeRow = (li: LineItem) => ` <tr> <td>${escape(li.description)}</td> <td>${li.qty}</td> </tr>`;
The hybrid pattern
In practice, most production systems end up hybrid. You use templates for the 90% of documents that are stable, and drop into raw HTML for the long tail.
A common pattern:
tsasync function renderDocument(doc: Doc) { if (doc.type === "invoice" || doc.type === "receipt") { return kamy.documents.render({ template: doc.type, data: doc.payload, }); } // Custom report — structure depends on which sections exist const html = buildReportHtml(doc); return kamy.documents.renderHtml({ html }); }
This keeps the common path boring and well-tested, while leaving an escape hatch for the unusual cases.
A quick decision checklist
Use templates if:
- The same layout will be rendered many times.
- A non-developer might edit the copy or layout.
- You care about versioning and auditability.
- The data shape is well-defined.
Use custom HTML if:
- The document structure is computed from the data.
- You're rendering once or in low volume.
- You need pixel-level control that's awkward to express in a template DSL.
Where Kamy fits
Kamy supports both modes through the same API. You can upload templates and render them by name with a data payload, or POST raw HTML for one-off documents. The P