Quick Summary

JSON-to-PDF separates content (JSON) from presentation (templates). Designers edit templates, developers send data — no HTML strings in code.


What is JSON-to-PDF and why does it matter?

Short answer: It decouples data from layout so you can reuse templates, enable non-dev edits, and reduce maintenance.

JSON to PDF generation separates data (the facts in your document) from presentation (how those facts look), solving the problem of mixing business logic with HTML strings in your code. Instead of concatenating strings or using template engines that embed in your application, you define a visual template once and send JSON data to generate PDFs, making it easy to change designs without touching code.


JSON to PDF generation separates data (the facts in your document) from presentation (how those facts look), solving the problem of mixing business logic with HTML strings in your code. Instead of concatenating strings or using template engines that embed in your application, you define a visual template once and send JSON data to generate PDFs, making it easy to change designs without touching code.

What's wrong with generating PDFs from HTML strings?

Short answer: HTML strings mix data and presentation, are brittle to maintain, and make designer collaboration hard.

Most developers start PDF generation by building HTML strings in code:


function generateInvoicePDF(invoice) {
  const html = `
    <html>
      <body>
        <h1>Invoice ${invoice.number}</h1>
        <p>Customer: ${invoice.customer}</p>
        <table>
          ${invoice.items.map(item => `
            <tr>
              <td>${item.description}</td>
              <td>${item.quantity}</td>
              <td>$${item.price}</td>
              <td>$${item.quantity * item.price}</td>
            </tr>
          `).join('')}
        </table>
        <p>Total: $${invoice.total}</p>
      </body>
    </html>
  `;
  
  return convertHTMLToPDF(html);
}

Problems with this approach:

  1. Data and presentation mixed: Business logic (calculating totals) mixed with HTML structure
  2. Hard to maintain: Change a color? Edit code, test, deploy
  3. Can't reuse designs: Each document type needs separate HTML generation code
  4. Designer-developer friction: Designers can't edit layouts without touching code
  5. Testing complexity: Must test both data logic and HTML rendering together

Real impact: After 6 months, you have 10+ HTML generation functions, each with embedded styles. A branding change means updating code in 10 places.

How has PDF generation evolved over time?

Short answer: From brittle HTML strings → template engines → visual template services that accept JSON and render PDFs reliably.

Stage 1: HTML String Concatenation


// Brittle: Mixing data, logic, and presentation
const html = '<html><body>' +
  '<h1>Invoice ' + invoice.number + '</h1>' +
  '<p>Customer: ' + invoice.customer + '</p>' +
  '</body></html>';

Pain point: One missing </div> breaks everything. No syntax highlighting or validation.

Stage 2: Template Engines (Handlebars, Pug, EJS)

// Better: Templates separate from code
const template = `
  <html>
    <body>
      <h1>Invoice {{number}}</h1>
      <p>Customer: {{customer}}</p>
      {{#each items}}
        <tr>
          <td>{{description}}</td>
          <td>{{quantity}}</td>
        </tr>
      {{/each}}
    </body>
  </html>
`;

const html = Handlebars.compile(template)(invoice);

Improvement: Templates are separate files, cleaner syntax

Remaining problems:

  • Templates live in codebase (still need deployment to change)
  • Designers need to learn Handlebars/Pug syntax
  • Complex layouts require template logic (loops, conditionals)
  • Still generating HTML, then converting to PDF (two steps)

Stage 3: Template Services with Visual Editors

// Best: Data only, template managed separately
const pdf = await fetch('https://api.hundreddocs.com/v1/pdf', {
  method: 'POST',
  headers: { 'X-API-Key': process.env.API_KEY },
  body: JSON.stringify({
    templateId: 'invoice-template',
    data: {
      number: 'INV-1234',
      customer: 'Acme Corp',
      items: [
        { description: 'Consulting', quantity: 10, price: 150 }
      ],
      total: 1500
    }
  })
});

Advantages:

  • No HTML in code: Send structured data only
  • Visual editor: Designers edit templates without code
  • Instant updates: Change template, all future PDFs update
  • Version control: Templates versioned separately from code
  • Single source of truth: One template generates all invoices

How does JSON-to-PDF actually work?

Short answer: Your app sends { templateId, data } to an API; the service merges data into the template and renders the PDF.

Architecture


┌─────────────────┐
│  Your App       │
│  (Node.js, etc) │
└────────┬────────┘
         │ 1. POST JSON data
         │    { templateId, data }
         ▼
┌─────────────────┐
│  Template API   │
│  (Hundred Docs) │
└────────┬────────┘
         │ 2. Fetch template definition
         │    (layout, fields, styles)
         ▼
┌─────────────────┐
│  Template Store │
│  (Database)     │
└────────┬────────┘
         │ 3. Return template
         ▼
┌─────────────────┐
│  Template API   │
│  Merge data     │
│  + template     │
└────────┬────────┘
         │ 4. Render PDF
         │    (Chrome engine)
         ▼
┌─────────────────┐
│  PDF Binary     │
│  (sent back)    │
└─────────────────┘

Template Definition (One Time)

Templates are created once via visual editor or API:


{
  "id": "invoice-template",
  "name": "Invoice Template",
  "pageSize": "A4",
  "header": {
    "logo": { "type": "image", "key": "companyLogo" },
    "invoiceNumber": { "type": "text", "key": "number", "style": "bold" },
    "date": { "type": "text", "key": "date" }
  },
  "content": {
    "customerInfo": {
      "type": "group",
      "fields": [
        { "key": "customerName", "label": "Bill To" },
        { "key": "customerAddress", "label": "Address" }
      ]
    },
    "lineItems": {
      "type": "table",
      "key": "items",
      "columns": [
        { "field": "description", "label": "Description" },
        { "field": "quantity", "label": "Qty" },
        { "field": "price", "label": "Price", "format": "currency" },
        { "field": "total", "label": "Total", "format": "currency" }
      ]
    },
    "totals": {
      "type": "group",
      "fields": [
        { "key": "subtotal", "label": "Subtotal", "format": "currency" },
        { "key": "tax", "label": "Tax", "format": "currency" },
        { "key": "total", "label": "Total", "format": "currency", "style": "bold" }
      ]
    }
  },
  "footer": {
    "pageNumbers": true,
    "text": "Thank you for your business!"
  }
}

Key concepts:

  • Fields: Named placeholders for data (key: "customerName")
  • Types: text, number, currency, date, image, table
  • Formatting: currency symbols, date formats, number precision
  • Layout: groups, tables, headers, footers

PDF Generation (Thousands of Times)

Send data only, template stays the same:

// Example 1: Invoice for customer A
const pdf1 = await generatePDF({
  templateId: 'invoice-template',
  data: {
    number: 'INV-1234',
    date: '2025-01-15',
    companyLogo: 'https://company.com/logo.png',
    customerName: 'Acme Corp',
    customerAddress: '123 Main St, City, State 12345',
    items: [
      { description: 'Consulting', quantity: 10, price: 150, total: 1500 },
      { description: 'Development', quantity: 20, price: 120, total: 2400 }
    ],
    subtotal: 3900,
    tax: 390,
    total: 4290
  }
});

// Example 2: Invoice for customer B (same template, different data)
const pdf2 = await generatePDF({
  templateId: 'invoice-template',
  data: {
    number: 'INV-1235',
    date: '2025-01-16',
    companyLogo: 'https://company.com/logo.png',
    customerName: 'Beta Industries',
    customerAddress: '456 Oak Ave, Town, State 67890',
    items: [
      { description: 'Support', quantity: 40, price: 100, total: 4000 }
    ],
    subtotal: 4000,
    tax: 400,
    total: 4400
  }
});

Result: Two different PDFs with same layout, different data. Change template once, both PDFs update.

Data Binding Concepts

Variable Substitution

Replace placeholders with values:

// Template field
{ "key": "customerName", "label": "Customer" }

// Data
{ "customerName": "Acme Corp" }

// Result in PDF
Customer: Acme Corp

Arrays and Loops (Tables, Line Items)

Render multiple rows from array data:

// Template table
{
  "type": "table",
  "key": "items",
  "columns": [
    { "field": "description" },
    { "field": "quantity" }
  ]
}

// Data (array)
{
  "items": [
    { "description": "Product A", "quantity": 5 },
    { "description": "Product B", "quantity": 3 }
  ]
}

// Result in PDF (table with 2 rows)
| Description | Quantity |
|-------------|----------|
| Product A   | 5        |
| Product B   | 3        |

Conditionals (Show/Hide Fields)

Display fields only when data exists:

// Template field (optional)
{
  "key": "notes",
  "label": "Notes",
  "showIf": "notes"  // Only render if data.notes exists
}

// Data with notes
{ "notes": "Payment due in 30 days" }
// Result: Notes field appears

// Data without notes
{ "notes": null }
// Result: Notes field hidden

Formatting (Dates, Currency, Numbers)

Transform values for display:

// Template field with format
{
  "key": "total",
  "format": "currency",
  "currency": "USD"
}

// Data (plain number)
{ "total": 1500.5 }

// Result in PDF
$1,500.50

Supported formats:

  • currency: Adds symbol, commas, 2 decimals
  • date: Converts ISO string to readable date
  • percent: Multiplies by 100, adds %
  • number: Adds thousand separators

Real-World Example: Invoice System

Before (HTML String Generation)

// invoice-generator.js (130 lines of HTML in code)
function generateInvoiceHTML(invoice) {
  return `
    <!DOCTYPE html>
    <html>
    <head>
      <style>
        body { font-family: Arial, sans-serif; }
        .header { display: flex; justify-content: space-between; }
        .logo { width: 150px; }
        .invoice-info { text-align: right; }
        table { width: 100%; border-collapse: collapse; }
        th, td { padding: 8px; border-bottom: 1px solid #ddd; }
        .totals { text-align: right; margin-top: 20px; }
        .total-row { font-weight: bold; font-size: 18px; }
      </style>
    </head>
    <body>
      <div class="header">
        <img src="${invoice.companyLogo}" class="logo" />
        <div class="invoice-info">
          <h2>Invoice ${invoice.number}</h2>
          <p>Date: ${invoice.date}</p>
        </div>
      </div>
      
      <div class="customer">
        <h3>Bill To:</h3>
        <p>${invoice.customerName}</p>
        <p>${invoice.customerAddress}</p>
      </div>
      
      <table>
        <thead>
          <tr>
            <th>Description</th>
            <th>Qty</th>
            <th>Price</th>
            <th>Total</th>
          </tr>
        </thead>
        <tbody>
          ${invoice.items.map(item => `
            <tr>
              <td>${item.description}</td>
              <td>${item.quantity}</td>
              <td>$${item.price.toFixed(2)}</td>
              <td>$${item.total.toFixed(2)}</td>
            </tr>
          `).join('')}
        </tbody>
      </table>
      
      <div class="totals">
        <p>Subtotal: $${invoice.subtotal.toFixed(2)}</p>
        <p>Tax: $${invoice.tax.toFixed(2)}</p>
        <p class="total-row">Total: $${invoice.total.toFixed(2)}</p>
      </div>
      
      <div class="footer">
        <p>Thank you for your business!</p>
      </div>
    </body>
    </html>
  `;
}

// Usage
const html = generateInvoiceHTML(invoiceData);
const pdf = await puppeteer.pdf(html);

Maintenance burden:

  • Branding change? Update CSS in code, test, deploy
  • Add field (PO number)? Update HTML structure, test, deploy
  • Designer feedback? Developer must translate to code

After (JSON to Template)

// No HTML generation code needed
async function generateInvoicePDF(invoice) {
  const response = await fetch('https://api.hundreddocs.com/v1/pdf', {
    method: 'POST',
    headers: {
      'X-API-Key': process.env.HUNDRED_DOCS_API_KEY,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      templateId: 'invoice-template',
      data: {
        companyLogo: invoice.companyLogo,
        number: invoice.number,
        date: invoice.date,
        customerName: invoice.customerName,
        customerAddress: invoice.customerAddress,
        items: invoice.items,
        subtotal: invoice.subtotal,
        tax: invoice.tax,
        total: invoice.total
      }
    })
  });
  
  return await response.arrayBuffer();
}

// Usage (same)
const pdf = await generateInvoicePDF(invoiceData);

Maintenance improvements:

  • Branding change? Edit template in visual editor, no deployment
  • Add field? Add to template, update data object, deploy
  • Designer feedback? Designer edits template directly

When to Use Each Approach

HTML Strings: One-Off Documents

Use when:

  • Generating <10 PDFs per month
  • Each PDF has completely unique structure
  • Need full control over every HTML element
  • Document is simple (1-2 pages, no complex layout)

Example: Personal portfolio PDF export, one-time contract

Template Engines: Moderate Volume, Technical Team

Use when:

  • Generating 10-100 PDFs per day
  • Same structure, different data (but still simple)
  • Technical team can maintain templates
  • Templates live in codebase (version controlled)

Example: Receipt generation, order confirmations

JSON to API: High Volume, Non-Technical Involvement

Use when:

  • Generating 100+ PDFs per day
  • Multiple document types (invoices, certificates, reports)
  • Designers or business users need to edit layouts
  • Want to update designs without code deployment
  • Template reuse is critical (same invoice template for 10,000 customers)

Example: Invoice generation, bulk certificate generation, multi-tenant PDF reports

Code Comparison: Complete Examples

Approach 1: HTML String Concatenation

// invoice.js (messy, hard to maintain)
function generateInvoice(data) {
  let html = '<html><head><style>';
  html += 'body { font-family: Arial; }';
  html += '.header { display: flex; }';
  html += '</style></head><body>';
  html += '<div class="header">';
  html += '<img src="' + data.logo + '" />';
  html += '<div>Invoice ' + data.number + '</div>';
  html += '</div>';
  html += '<table>';
  for (let item of data.items) {
    html += '<tr>';
    html += '<td>' + item.description + '</td>';
    html += '<td>' + item.quantity + '</td>';
    html += '</tr>';
  }
  html += '</table>';
  html += '</body></html>';
  
  return convertToPDF(html);
}

Issues: No syntax highlighting, easy to break, mixed concerns

Approach 2: Template Engine (Handlebars)

// invoice-template.hbs (separate file, better)
const templateSource = `
  <html>
    <head>
      <style>
        body { font-family: Arial; }
        .header { display: flex; }
      </style>
    </head>
    <body>
      <div class="header">
        <img src="{{logo}}" />
        <div>Invoice {{number}}</div>
      </div>
      <table>
        {{#each items}}
          <tr>
            <td>{{description}}</td>
            <td>{{quantity}}</td>
          </tr>
        {{/each}}
      </table>
    </body>
  </html>
`;

// invoice.js (cleaner code)
const Handlebars = require('handlebars');
const template = Handlebars.compile(templateSource);

function generateInvoice(data) {
  const html = template(data);
  return convertToPDF(html);
}

Better, but: Still generating HTML, templates in code, designers need Handlebars knowledge

Approach 3: JSON to Template API

// invoice.js (cleanest, no HTML)
async function generateInvoice(data) {
  const response = await fetch('https://api.hundreddocs.com/v1/pdf', {
    method: 'POST',
    headers: {
      'X-API-Key': process.env.API_KEY,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      templateId: 'invoice-template', // Created via visual editor
      data: {
        logo: data.logo,
        number: data.number,
        items: data.items
      }
    })
  });
  
  return await response.arrayBuffer();
}

Best because:

  • No HTML in code
  • Template managed separately
  • Designer can edit without deployment
  • Data structure is explicit and validated

Technical takeaway: JSON to PDF separates data from presentation by defining templates once (visually or via API) and sending structured data to generate PDFs. This eliminates HTML string generation, enables non-technical users to edit designs, and scales to thousands of PDFs without code changes. Use HTML strings for one-off documents, template engines for moderate volume with technical teams, and JSON APIs for high volume with design iteration.