Get started
Using LLMs to generate PDF templates from a prompt

Using LLMs to generate PDF templates from a prompt

Turn a plain-language prompt into a reusable HTML and Handlebars PDF template with an LLM, then render it to PDF on demand. Patterns, prompts and guardrails.

11 min read

An LLM should generate the PDF template once, not the PDF on every request. The winning pattern is: prompt the model for self-contained HTML with inline CSS and Handlebars placeholders, validate and store that template, then render it deterministically with your data through a renderer like PDF4.dev. This keeps output reproducible, cheap, and fast: you pay model tokens a single time, then every PDF is a plain variable fill.

This article shows the prompt that produces a clean template, the guardrails that stop the model from inventing data, and the loop that turns a one-line request into a production PDF endpoint.

Should the LLM generate the template or the final PDF?

Generate the template, once, at design time. The model writes the reusable HTML and Handlebars markup. A deterministic renderer then fills variables on every request. Generating the finished document on each render means paying tokens every time, waiting seconds per PDF, and getting output that changes between identical inputs.

The two patterns differ on cost, speed, and reproducibility:

CriterionLLM generates template onceLLM generates each PDF
Token costPaid one time at designPaid on every document
Latency per PDFRender only (around 300ms)Model call plus render (seconds)
Reproducible outputYes, same data, same bytesNo, varies per call
AuditabilityDiff the stored templateNothing stable to diff
Best forHigh-volume, fixed layoutsOne-off, throwaway documents

For invoices, receipts, certificates, contracts, and reports (anything you produce more than once), the template-once pattern is the only sensible choice. The model is a design tool, not a runtime dependency.

Treat the LLM like a senior front-end engineer who builds the template, not a print server that runs on every request. The template is committed, reviewed, and versioned like any other code.

How do you prompt an LLM for a clean PDF template?

Ask for one self-contained HTML file with inline CSS, A4 print rules, and Handlebars placeholders for every dynamic value. The prompt must pin the exact variable names so the output matches your data, and forbid real sample data so nothing leaks into production. A precise prompt matters more than the model choice.

Here is a prompt that produces a render-ready invoice template:

You are generating a reusable PDF template, not a finished document.
 
Output: a single self-contained HTML file. Requirements:
- All CSS inline in one <style> tag in <head>. No external stylesheets, no CDN links.
- Page setup for A4: @page { size: A4; margin: 18mm; }. Use mm and pt units.
- Use Handlebars placeholders for every dynamic value. Do NOT write real data.
- Use exactly these variables, no others:
    company_name, company_address, invoice_number, issue_date, due_date,
    customer_name, customer_address, line_items (array of { description, qty, unit_price, amount }),
    subtotal, tax, total, currency
- Loop line items with {{#each line_items}} ... {{/each}}.
- System fonts only (font-family: Arial, Helvetica, sans-serif). No web fonts.
- Black text on white, one accent color #111827. No images, no JavaScript.
 
Return only the HTML. No explanation.

The constraints that matter: inline CSS (the renderer gets one file with no network dependency), the explicit variable list (the model cannot drift to {{client}} when your data has customer_name), and the "no real data" rule (placeholders stay placeholders). The @page block and mm/pt units give Chromium correct print geometry.

Never let the prompt request "a sample invoice for Acme Corp". The model will hard-code Acme into the markup and you will ship it. Always ask for placeholders, then fill them at render time.

What does a generated template look like?

A correct template is plain HTML with Handlebars tokens where data goes and no hard-coded values. The {{#each}} helper handles repeating rows, and the CSS lives inline so the renderer needs nothing else. Below is a trimmed version of what the prompt above returns.

<!doctype html>
<html>
<head>
  <meta charset="utf-8" />
  <style>
    @page { size: A4; margin: 18mm; }
    body { font-family: Arial, Helvetica, sans-serif; color: #111827; font-size: 11pt; }
    .head { display: flex; justify-content: space-between; margin-bottom: 24pt; }
    h1 { font-size: 20pt; margin: 0; }
    table { width: 100%; border-collapse: collapse; margin-top: 16pt; }
    th, td { text-align: left; padding: 6pt 8pt; border-bottom: 1px solid #e5e7eb; }
    td.num, th.num { text-align: right; }
    .totals { margin-top: 16pt; width: 240pt; margin-left: auto; }
  </style>
</head>
<body>
  <div class="head">
    <div>
      <h1>{{company_name}}</h1>
      <div>{{company_address}}</div>
    </div>
    <div>
      <div>Invoice {{invoice_number}}</div>
      <div>Issued {{issue_date}}</div>
      <div>Due {{due_date}}</div>
    </div>
  </div>
 
  <div><strong>Bill to:</strong> {{customer_name}}, {{customer_address}}</div>
 
  <table>
    <thead>
      <tr><th>Description</th><th class="num">Qty</th><th class="num">Unit</th><th class="num">Amount</th></tr>
    </thead>
    <tbody>
      {{#each line_items}}
      <tr>
        <td>{{description}}</td>
        <td class="num">{{qty}}</td>
        <td class="num">{{unit_price}}</td>
        <td class="num">{{amount}}</td>
      </tr>
      {{/each}}
    </tbody>
  </table>
 
  <table class="totals">
    <tr><td>Subtotal</td><td class="num">{{subtotal}} {{currency}}</td></tr>
    <tr><td>Tax</td><td class="num">{{tax}} {{currency}}</td></tr>
    <tr><td><strong>Total</strong></td><td class="num"><strong>{{total}} {{currency}}</strong></td></tr>
  </table>
</body>
</html>

Notice there is no Acme Corp, no $1,500, no fake address. Every value is a token. That is the signal the template is reusable.

How do you validate LLM-generated template HTML?

Run three checks before trusting any generated template: parse the HTML to confirm it is well-formed, extract every Handlebars token and compare it against your expected variable list, and do one test render with sample data. A template that passes all three is safe to store. One that references an unknown variable or has an unbalanced {{#each}} block gets rejected.

The token-schema check is the important guardrail. The model can quietly add a {{discount}} you never planned for, and that variable will render blank in production. Pin the schema and fail loudly on drift.

// Allowed variables for this template type (the pinned schema).
const allowed = new Set([
  "company_name", "company_address", "invoice_number", "issue_date",
  "due_date", "customer_name", "customer_address", "line_items",
  "description", "qty", "unit_price", "amount",
  "subtotal", "tax", "total", "currency",
]);
 
function extractTokens(html: string): string[] {
  // Match {{ name }}, {{#each name}}, {{/each}}, {{#if name}} ...
  const re = /\{\{[#/]?(?:each |if |unless )?\s*([a-zA-Z_][a-zA-Z0-9_]*)/g;
  const found = new Set<string>();
  for (const m of html.matchAll(re)) found.add(m[1]);
  return [...found];
}
 
function validateTemplate(html: string): string[] {
  const errors: string[] = [];
  const opens = (html.match(/\{\{#each/g) || []).length;
  const closes = (html.match(/\{\{\/each\}\}/g) || []).length;
  if (opens !== closes) errors.push("Unbalanced each blocks");
 
  for (const token of extractTokens(html)) {
    if (!allowed.has(token)) errors.push(`Unknown variable: ${token}`);
  }
  return errors;
}
 
const errors = validateTemplate(generatedHtml);
if (errors.length) throw new Error(`Reject template: ${errors.join(", ")}`);

After the static checks pass, do one render with realistic sample data and look at the PDF. Static validation catches broken markup, but only a real render catches a table that overflows the page margin or a font that falls back to Times.

How do you render the generated template to a PDF?

Store the validated HTML, then POST it with a data object to a renderer that turns HTML into PDF. PDF4.dev takes the template HTML and your data at POST https://pdf4.dev/api/v1/render and returns a PDF rendered with headless Chromium, so you do not run or patch a browser yourself. The Handlebars tokens are filled server-side with the data you send.

You can paste a generated template straight into our free HTML to PDF toolTry it free to eyeball the layout before wiring up the API.

A minimal raw-HTML render with curl:

curl -X POST https://pdf4.dev/api/v1/render \
  -H "Authorization: Bearer p4_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "html": "<h1>Invoice {{invoice_number}}</h1><p>{{customer_name}}</p>",
    "data": { "invoice_number": "INV-1042", "customer_name": "Riverbank Ltd" },
    "delivery": "url"
  }'

In production you store the template once and reference it by id, then send only data per document:

// 1. Save the LLM-generated, validated template (returns a template_id).
const created = await fetch("https://pdf4.dev/api/v1/templates", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.PDF4_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ name: "Invoice", html: generatedHtml }),
}).then((r) => r.json());
 
// 2. Render a PDF by filling variables. No model call here.
const res = await fetch("https://pdf4.dev/api/v1/render", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.PDF4_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    template_id: created.id,
    data: {
      invoice_number: "INV-1042",
      customer_name: "Riverbank Ltd",
      line_items: [{ description: "Design retainer", qty: 1, unit_price: "1200.00", amount: "1200.00" }],
      total: "1200.00",
      currency: "EUR",
    },
    delivery: "url",
  }),
});
 
const { url } = await res.json();

The render step never calls an LLM. Storing the template separates the expensive, non-deterministic design phase from the cheap, deterministic production phase. That is the whole point of the pattern.

Can an AI agent create and render templates directly?

Yes. PDF4.dev exposes a Model Context Protocol (MCP) server, so an agent like Claude or a custom GPT can call create_template once with the LLM-generated HTML, then render_pdf with data for each document. The agent owns the full loop: design the template, store it, and produce PDFs, without you writing glue code between the model and the renderer.

The flow inside an agent session looks like this:

  1. The user asks for a "delivery note template with a logo placeholder and a signature line".
  2. The agent generates the HTML and Handlebars markup following the prompt rules above.
  3. The agent calls create_template over MCP and gets back a template_id.
  4. The agent calls render_pdf with sample data to preview, then iterates if the user wants changes.
  5. Real data flows through render_pdf for every production document.

Because the template is stored server-side, step 5 repeats forever at render cost only. The model is involved in steps 1 to 4, then steps out. For a full walkthrough of the agent loop, see generate PDFs with AI agents over MCP and designing PDFs with Claude through conversation.

MCP turns "describe the document" into a stored, reusable template in one conversation. The agent does the HTML, the validation, and the first render, and you keep a versioned artifact at the end.

What guardrails matter most?

Four guardrails keep LLM-generated templates safe: pin the variable schema, forbid real data, validate before storing, and version every template. Skip any one and you get silent breakage, leaked sample content, or output you cannot reproduce. These are not optional extras, they are what makes the pattern production-grade.

GuardrailWhat it preventsHow
Pinned variable schemaModel invents {{discount}} that renders blankList exact variables in the prompt, reject unknown tokens
No real data in templatesAcme Corp shipped to a real customerRequire placeholders, fail on hard-coded values
Validate before storeBroken HTML or unbalanced blocks in productionParse, token-check, and test-render every output
Version templatesSilent layout drift across regenerationsCommit each template, diff changes, roll back on regression

The model is good at producing markup and bad at remembering your data contract across a long conversation. Encode the contract in the prompt and enforce it in code. Never trust the model to self-police the variable list.

Which approach should you choose?

Match the pattern to how often you produce the document. For anything repeated, generate the template once with an LLM and render deterministically. For genuine one-offs, a direct generation is fine. Use a renderer like PDF4.dev when you do not want to run Chromium yourself.

  • High-volume, fixed layout (invoices, receipts, certificates): LLM generates the template once, validate it, store it, render per request with data. Lowest cost, fully reproducible.
  • You want an agent to own the loop: Use the PDF4.dev MCP server so Claude or a GPT-based agent creates and renders templates in one session.
  • Rare, throwaway document: A single LLM generation plus an immediate render is acceptable. The token cost is small because it happens once.
  • You already run a print server: Keep the template-once pattern, but you can render with your own headless Chromium instead of a hosted API. The validation and schema-pinning guardrails still apply.

The constant across every scenario: the LLM builds the template, a deterministic engine fills the data. Keep those two phases separate and PDF generation stays fast, cheap, and predictable.

For the broader picture of where AI fits across document workflows, read the complete guide to AI document generation.

Free tools mentioned:

Html To PdfTry it free

Start generating PDFs

Build PDF templates with a visual editor. Render them via API from any language in ~300ms.