Invalid Date

Need to generate hundreds of PDFs with the same layout but different data? Invoices, certificates, reports — most business documents follow this pattern.

With a template engine, you create the HTML layout once and inject data to generate as many PDFs as you need.

What is a PDF Template Engine?

A PDF template engine replaces {{variable_name}} placeholders in HTML with actual data before converting to PDF.

HTML Template + Data (JSON) → PDF

Template Example

<h1>Certificate of Completion</h1>
<p>This certifies that {{student_name}}</p>
<p>has completed {{course_name}}.</p>
<p>Date: {{completion_date}}</p>

Data Example

{
  "student_name": "Jane Smith",
  "course_name": "Security Fundamentals",
  "completion_date": "March 26, 2026"
}

Result

Certificate of Completion
This certifies that Jane Smith
has completed Security Fundamentals.
Date: March 26, 2026

Using FUNBREW PDF Templates

1. Create a Template

Build your HTML in the dashboard template editor. It features CodeMirror with live preview — edit on the left, see the result on the right.

2. Define Variables

Use {{variable_name}} syntax in your HTML. The editor auto-detects variables and displays them in the definition panel.

Each variable supports:

Setting Description
name Variable name
required Whether it's mandatory
default_value Fallback when not provided
description Usage notes

3. Generate via API

curl -X POST https://pdf.funbrew.cloud/api/pdf/generate-from-template \
  -H "Authorization: Bearer sk-your-api-key" \
  -H "Content-Type: application/json" \
  -d '{
    "template": "certificate",
    "variables": {
      "student_name": "Jane Smith",
      "course_name": "Security Fundamentals",
      "completion_date": "March 26, 2026"
    }
  }'

Code Examples by Language

Python

import httpx
import os

FUNBREW_PDF_API_KEY = os.environ["FUNBREW_PDF_API_KEY"]

def generate_from_template(
    template_slug: str,
    variables: dict,
) -> bytes:
    """Generate a PDF from a registered template."""
    response = httpx.post(
        "https://pdf.funbrew.cloud/api/pdf/generate-from-template",
        headers={
            "Authorization": f"Bearer {FUNBREW_PDF_API_KEY}",
            "Content-Type": "application/json",
        },
        json={
            "template": template_slug,
            "variables": variables,
        },
        timeout=30.0,
    )
    response.raise_for_status()
    return response.content

# Generate an invoice
invoice_vars = {
    "customer_name": "Acme Corp",
    "invoice_number": "INV-2026-001",
    "issue_date": "March 31, 2026",
    "due_date": "April 30, 2026",
    "line_items": """
        <tr><td>API Usage - Basic Plan</td><td>1</td><td>$29.00</td></tr>
        <tr><td>Extra PDFs (500 docs)</td><td>500</td><td>$15.00</td></tr>
    """,
    "subtotal": "$44.00",
    "tax": "$0.00",
    "total": "$44.00",
}

pdf_bytes = generate_from_template("invoice", invoice_vars)
with open("invoice_2026_001.pdf", "wb") as f:
    f.write(pdf_bytes)
print("Invoice saved.")

PHP

<?php

function generateFromTemplate(string $templateSlug, array $variables): string
{
    $apiKey = getenv('FUNBREW_PDF_API_KEY');

    $ch = curl_init('https://pdf.funbrew.cloud/api/pdf/generate-from-template');
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_POST           => true,
        CURLOPT_HTTPHEADER     => [
            'Authorization: Bearer ' . $apiKey,
            'Content-Type: application/json',
        ],
        CURLOPT_POSTFIELDS => json_encode([
            'template'  => $templateSlug,
            'variables' => $variables,
        ]),
        CURLOPT_TIMEOUT => 30,
    ]);

    $response = curl_exec($ch);
    $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    if ($httpCode !== 200) {
        throw new RuntimeException("PDF generation failed with HTTP {$httpCode}");
    }

    return $response;
}

// Generate a certificate
$pdfBytes = generateFromTemplate('certificate', [
    'student_name'    => 'John Doe',
    'course_name'     => 'Advanced PHP Development',
    'completion_date' => 'March 31, 2026',
    'certificate_id'  => 'CERT-2026-0042',
    'issuer_name'     => 'FUNBREW Academy',
]);

file_put_contents('certificate.pdf', $pdfBytes);
echo "Certificate generated.\n";

Node.js

const fs = require('fs');

async function generateFromTemplate(templateSlug, variables) {
  const response = await fetch(
    'https://pdf.funbrew.cloud/api/pdf/generate-from-template',
    {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${process.env.FUNBREW_PDF_API_KEY}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ template: templateSlug, variables }),
    }
  );

  if (!response.ok) {
    const body = await response.text();
    throw new Error(`PDF generation failed: ${response.status} ${body}`);
  }

  return Buffer.from(await response.arrayBuffer());
}

// Monthly report with dynamic chart data
async function generateMonthlyReport(month, salesData) {
  const chartRows = salesData
    .map(d => `<tr><td>${d.week}</td><td>$${d.revenue.toLocaleString()}</td></tr>`)
    .join('');

  const pdf = await generateFromTemplate('monthly-report', {
    report_period: month,
    chart_rows: chartRows,
    total_revenue: `$${salesData.reduce((s, d) => s + d.revenue, 0).toLocaleString()}`,
  });

  fs.writeFileSync(`report-${month}.pdf`, pdf);
  console.log(`Report for ${month} saved.`);
}

generateMonthlyReport('March 2026', [
  { week: 'Week 1', revenue: 12400 },
  { week: 'Week 2', revenue: 15800 },
  { week: 'Week 3', revenue: 11200 },
  { week: 'Week 4', revenue: 18600 },
]);

Practical Techniques

Passing HTML as a Variable (Dynamic Rows)

For table rows where the count varies, pass pre-built HTML as a variable:

<!-- Template -->
<table>
  <thead>
    <tr><th>Item</th><th>Qty</th><th>Amount</th></tr>
  </thead>
  <tbody>{{line_items}}</tbody>
</table>

Build the rows in your application code:

const lineItems = items.map(item =>
  `<tr><td>${item.name}</td><td>${item.qty}</td><td>$${item.price.toFixed(2)}</td></tr>`
).join('');

// variables: { "line_items": lineItems }

Using Default Values

Set default values for variables that rarely change. For example, set a default bank account for payment details — most invoices use the default, but you can override it for specific customers.

Conditional Content

The template engine doesn't have if/else syntax, but you can handle conditions in your application code:

const note = isPaid
  ? '<p style="color: green">Payment received</p>'
  : '<p style="color: red">Payment due: ' + dueDate + '</p>';

// variables: { "payment_status": note }

Template Engine Comparison

When choosing a template approach for PDF generation, developers often consider existing engines like Handlebars, Jinja2, or Blade. Here's how they compare to FUNBREW PDF's approach.

Feature Handlebars Jinja2 Blade FUNBREW PDF
Language JavaScript Python PHP Language-agnostic (API)
Conditionals {{#if}} {% if %} @if App-side logic
Loops {{#each}} {% for %} @foreach App-side logic
Filters Helper functions Pipe syntax PHP functions App-side processing
PDF output Requires separate conversion Requires separate conversion Requires separate conversion Single API call

Challenges with Traditional Template Engines

Handlebars, Jinja2, and Blade are all excellent template engines, but they come with challenges when used for PDF generation:

  1. Language lock-in: Handlebars requires JavaScript, Jinja2 requires Python, and Blade requires PHP. Switching your tech stack means migrating all your templates.
  2. Separate PDF conversion step: After rendering HTML from a template, you still need a separate tool like wkhtmltopdf or Puppeteer to convert to PDF. Installing, configuring, and maintaining these tools adds operational overhead.
  3. Logic mixed into templates: When conditionals and loops live inside templates, collaboration with designers becomes harder and testing grows more complex.

The FUNBREW PDF Approach

FUNBREW PDF keeps templates as pure HTML with {{variable_name}} placeholders. All conditional logic, loops, and data formatting happen in your application code.

The benefits of this approach:

  • Language-agnostic: Since it's a REST API, the same template works from Python, PHP, Node.js, Go, Ruby, or any HTTP-capable language
  • Simple templates: Only HTML and CSS knowledge is needed to create and edit templates, making it easy to collaborate with designers
  • Easy to test: Logic lives in your application code and can be covered by standard unit tests
  • No infrastructure: No need to manage PDF conversion engines -- just call the API and get a PDF back

For full API specifications and supported engines, see the documentation.

Advanced Conditional Patterns

As mentioned earlier, FUNBREW PDF's template engine doesn't have built-in if/else syntax, but you can build sophisticated conditional output in your application code before passing it as a variable.

Multiple Conditions (Switch-like Patterns)

Display different status badges based on a value:

function statusBadge(status) {
  const badges = {
    paid:      '<span style="background:#22c55e;color:#fff;padding:4px 12px;border-radius:4px">Paid</span>',
    pending:   '<span style="background:#f59e0b;color:#fff;padding:4px 12px;border-radius:4px">Pending</span>',
    overdue:   '<span style="background:#ef4444;color:#fff;padding:4px 12px;border-radius:4px">Overdue</span>',
    cancelled: '<span style="background:#6b7280;color:#fff;padding:4px 12px;border-radius:4px">Cancelled</span>',
  };
  return badges[status] || badges.pending;
}

// variables: { "status_badge": statusBadge(invoice.status) }

Showing or Hiding Entire Sections

Conditionally include or exclude entire sections based on your data:

# Discount section (only shown when there is a discount)
if discount_amount > 0:
    discount_section = f"""
    <div style="border:1px dashed #f59e0b;padding:12px;margin:16px 0;border-radius:8px">
      <strong>Discount Applied</strong>: {discount_name}<br>
      Amount: ${discount_amount:,.2f}
    </div>
    """
else:
    discount_section = ""

# variables: { "discount_section": discount_section }

Locale-Aware Currency Formatting

Format amounts differently depending on the target locale:

def format_currency(amount, locale="en"):
    if locale == "ja":
        return f"¥{amount:,.0f}"
    elif locale == "en":
        return f"${amount:,.2f}"
    elif locale == "eu":
        return f"€{amount:,.2f}"
    else:
        return f"{amount:,.2f}"

# US invoice
variables = {
    "total_amount": format_currency(450.00, "en"),   # → $450.00
    "currency_note": "USD (tax included)",
}

# Japanese invoice
variables = {
    "total_amount": format_currency(49500, "ja"),     # → ¥49,500
    "currency_note": "JPY (tax included)",
}

For more on multilingual document generation, see the certificate automation guide.

Working with Loops and Nested Data

Since the template engine doesn't include loop syntax, you build HTML from arrays in your application code and pass the result as a variable. This gives you full flexibility for complex data structures.

Building Complex Nested Tables

Here's an example of a table grouped by department:

function buildDepartmentTable(departments) {
  let html = '';
  for (const dept of departments) {
    // Department header row
    html += `<tr style="background:#f1f5f9">
      <td colspan="4" style="font-weight:bold;padding:8px">${dept.name}</td>
    </tr>`;
    // Line items within the department
    for (const item of dept.items) {
      html += `<tr>
        <td style="padding:4px 8px 4px 24px">${escapeHtml(item.name)}</td>
        <td style="text-align:right">${item.quantity}</td>
        <td style="text-align:right">$${item.unitPrice.toFixed(2)}</td>
        <td style="text-align:right">$${(item.quantity * item.unitPrice).toFixed(2)}</td>
      </tr>`;
    }
    // Department subtotal
    const subtotal = dept.items.reduce((s, i) => s + i.quantity * i.unitPrice, 0);
    html += `<tr style="border-top:1px solid #cbd5e1">
      <td colspan="3" style="text-align:right;padding:4px 8px"><em>Subtotal</em></td>
      <td style="text-align:right;font-weight:bold">$${subtotal.toFixed(2)}</td>
    </tr>`;
  }
  return html;
}

// variables: { "department_items": buildDepartmentTable(data.departments) }

Generating Multiple Pages from Array Data

Create one page per employee for payslips using CSS page-break-after:

def build_payslip_pages(employees):
    pages = []
    for i, emp in enumerate(employees):
        page_break = 'page-break-after: always;' if i < len(employees) - 1 else ''
        page = f"""
        <div style="{page_break} padding: 40px;">
          <h2>Pay Stub - {emp['name']}</h2>
          <table style="width:100%;border-collapse:collapse">
            <tr><td>Base Salary</td><td style="text-align:right">${emp['base_salary']:,.2f}</td></tr>
            <tr><td>Overtime</td><td style="text-align:right">${emp['overtime']:,.2f}</td></tr>
            <tr><td>Commute Allowance</td><td style="text-align:right">${emp['commute']:,.2f}</td></tr>
            <tr style="border-top:2px solid #000;font-weight:bold">
              <td>Total</td>
              <td style="text-align:right">${emp['base_salary'] + emp['overtime'] + emp['commute']:,.2f}</td>
            </tr>
          </table>
          <p style="margin-top:24px;color:#6b7280;font-size:12px">Issued: {emp['issue_date']}</p>
        </div>
        """
        pages.append(page)
    return "\n".join(pages)

# variables: { "payslip_pages": build_payslip_pages(employees) }

For more on controlling page breaks and print layout, see the HTML to PDF CSS guide.

Handling Empty Arrays Gracefully

Show a friendly message when there's no data:

function buildTableOrEmpty(items, emptyMessage = 'No data available') {
  if (!items || items.length === 0) {
    return `<tr><td colspan="4" style="text-align:center;color:#9ca3af;padding:24px">${emptyMessage}</td></tr>`;
  }
  return items.map(item =>
    `<tr>
      <td>${escapeHtml(item.name)}</td>
      <td style="text-align:right">${item.qty}</td>
      <td style="text-align:right">$${item.price.toFixed(2)}</td>
      <td style="text-align:right">$${(item.qty * item.price).toFixed(2)}</td>
    </tr>`
  ).join('');
}

// variables: { "line_items": buildTableOrEmpty(order.items) }

Template Version Management

When running templates in production, version management is critical. Here are practices to prevent template changes from breaking existing documents.

Version Suffixes in Template Slugs

Add version suffixes to your template slugs:

invoice-v1      ← current production template
invoice-v2      ← new design being tested
invoice-v2-bold ← A/B test variation

Switch templates by changing the slug in your API call:

const templateSlug = featureFlag('new_invoice_design')
  ? 'invoice-v2'
  : 'invoice-v1';

const pdf = await generateFromTemplate(templateSlug, variables);

A/B Testing Template Designs

With slug-based templates, A/B testing different designs is straightforward:

import random

def get_template_slug(base_slug, ab_test_config=None):
    if ab_test_config and ab_test_config.get("enabled"):
        variants = ab_test_config["variants"]  # {"invoice-v2": 50, "invoice-v2-bold": 50}
        rand = random.randint(1, 100)
        cumulative = 0
        for variant, weight in variants.items():
            cumulative += weight
            if rand <= cumulative:
                return variant
    return base_slug

template = get_template_slug("invoice-v1", {
    "enabled": True,
    "variants": {"invoice-v2": 50, "invoice-v2-bold": 50}
})

Rollback Strategy

If a template update causes issues, follow this rollback approach:

  1. Keep old versions: Never overwrite a template -- create a new slug for each version
  2. Config-driven switching: Store the active template slug in an environment variable or config file so you can switch without a deploy
  3. Log generation history: Record which template version generated each PDF so you can trace issues back to specific changes

For more on production stability, see the PDF API production checklist. To experiment with template versioning hands-on, try the Playground.

Template Design by Use Case

Invoices

Variables: customer_name, invoice_number, issue_date, line_items, subtotal, tax, total, due_date, bank_details

Tip: Pass line items as HTML, calculate totals server-side.

Certificates

Variables: student_name, course_name, completion_date, certificate_id, issuer_name

Tip: Use landscape orientation, large fonts, centered layout.

Monthly Reports

Variables: report_period, summary_html, chart_image_url, detail_table

Tip: Embed charts as Base64 images or pre-generate image URLs.

Template Design Best Practices

A few guidelines that improve maintainability and rendering reliability:

  • Design for reuse: Make logos and company names default-value variables so one template covers multiple clients
  • Use inline styles: Like email clients, PDF rendering is most stable with inline CSS
  • Specify fonts explicitly: Use font-family: 'Noto Sans JP', sans-serif for CJK content — FUNBREW PDF includes these pre-installed
  • Use @media print CSS: Control page breaks and headers/footers. Behavior varies by engine — see the engine selection guide

If you're evaluating templates in the context of API selection, our HTML to PDF API comparison covers how different services handle template features.

Troubleshooting

Variables not replaced (rendered as {{variable_name}})

Cause: The variable name in the API request does not match the placeholder in the template.

Fix: Check for typos and case sensitivity. Variable names are case-sensitive — {{Student_Name}} and {{student_name}} are different.

# Inspect which variables the template expects
curl -s -H "Authorization: Bearer $FUNBREW_PDF_API_KEY" \
  "https://pdf.funbrew.cloud/api/templates/certificate" | jq '.variables[].name'

Missing required variable error

Cause: A variable marked as required was not provided in the API request.

Fix: Either provide the variable, or set a default_value in the template editor so the field is optional at call time.

Table rows not rendering correctly

Cause: HTML special characters (<, >, &) in line item values are not escaped.

Fix: Sanitize dynamic values before embedding them in HTML:

function escapeHtml(str) {
  return String(str)
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;');
}

const lineItems = items.map(item =>
  `<tr><td>${escapeHtml(item.name)}</td><td>${item.qty}</td><td>$${item.price.toFixed(2)}</td></tr>`
).join('');

Layout breaks with certain engines

Cause: CSS Grid or Flexbox may not render correctly with the fast engine (wkhtmltopdf).

Fix: Switch to "engine": "quality" for layout-heavy templates. See the engine comparison guide for details.

PDF is blank or partially rendered

Cause: External resources (fonts, images) failed to load during rendering.

Fix: Use inline Base64-encoded images and hosted web fonts, or embed all styles inline. External URLs must be publicly accessible at render time.

Summary

Key points for template-based PDF generation:

  1. Create once, reuse forever — HTML templates are reusable
  2. {{variables}} for dynamic data injection
  3. Default values for flexible operations
  4. Pass HTML as variables for dynamic table rows
  5. Sanitize user input before embedding in HTML

Try the template editor in the dashboard → Log in

Don't have an account? Sign up free. Check the Playground to experiment with templates directly in your browser.

Related

Powered by FUNBREW PDF