Generate Invoice Images from HTML Templates in Any Framework

Invoice generation sits in an awkward corner of most SaaS products. You need documents that look professional, carry your brand, contain dynamic line items and totals, and can be downloaded or emailed. The common solutions — PDF generation libraries, headless Chrome, LaTeX — each bring their own installation pain and cross-platform surprises.

HTML and CSS solve the design problem better than any of them. You already know the tools. A render API handles the conversion to a high-resolution image. This guide builds a complete invoice endpoint that accepts order data and returns a downloadable PNG, ready to email or attach to a payment record.

Why a PNG instead of a PDF?

PNG invoices are not the right choice for every business — if you need multi-page documents or selectable text for accounting software imports, a true PDF library is worth the complexity. But for most SaaS billing scenarios, a high-resolution PNG works well:

If your invoices are single-page and you primarily email them or attach them to a customer portal, PNG is the simpler path.

The invoice HTML template

An invoice has a predictable structure: header with your brand and invoice metadata, a line items table, a totals section, and a footer with payment terms. Fix the body dimensions to A4 proportions at screen resolution — 794×1123 px at 96 dpi maps to A4 at roughly 100% zoom:

html — invoice template
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }
  body {
    width: 794px; height: 1123px; overflow: hidden;
    background: #fff; font-family: -apple-system, 'Segoe UI', sans-serif;
    font-size: 13px; color: #111; padding: 56px;
  }
  .header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 48px; }
  .brand { font-size: 22px; font-weight: 700; color: #111; letter-spacing: -.4px; }
  .brand span { color: #22d3ee; }
  .invoice-meta { text-align: right; }
  .invoice-meta .label { font-size: 11px; color: #888; text-transform: uppercase; letter-spacing: .05em; }
  .invoice-meta .value { font-size: 15px; font-weight: 600; margin-top: 2px; }
  .invoice-meta .number { font-size: 13px; color: #555; margin-top: 4px; }
  .addresses { display: flex; gap: 48px; margin-bottom: 40px; }
  .address-block .title { font-size: 10px; text-transform: uppercase; letter-spacing: .1em; color: #888; margin-bottom: 8px; }
  .address-block p { line-height: 1.6; color: #333; }
  .items-table { width: 100%; border-collapse: collapse; margin-bottom: 0; }
  .items-table th { font-size: 10px; text-transform: uppercase; letter-spacing: .08em; color: #888; padding: 8px 0; border-bottom: 1px solid #e5e5e5; text-align: left; }
  .items-table th:last-child, .items-table td:last-child { text-align: right; }
  .items-table td { padding: 12px 0; border-bottom: 1px solid #f0f0f0; color: #333; vertical-align: top; }
  .items-table td .desc { font-weight: 500; color: #111; }
  .items-table td .sub { font-size: 11px; color: #888; margin-top: 2px; }
  .totals { margin-left: auto; width: 240px; margin-top: 24px; }
  .totals-row { display: flex; justify-content: space-between; padding: 5px 0; font-size: 13px; color: #555; }
  .totals-row.total { border-top: 1px solid #111; margin-top: 8px; padding-top: 12px; font-size: 16px; font-weight: 700; color: #111; }
  .footer { position: absolute; bottom: 40px; left: 56px; right: 56px; border-top: 1px solid #eee; padding-top: 16px; display: flex; justify-content: space-between; }
  .footer p { font-size: 11px; color: #aaa; }
</style>
</head>
<body>
  <div class="header">
    <div class="brand">Acme<span>.</span>Inc</div>
    <div class="invoice-meta">
      <div class="label">Invoice</div>
      <div class="value">Due March 25, 2026</div>
      <div class="number">#INV-2026-0042</div>
    </div>
  </div>
  ...
</body>
</html>

Building the data-driven endpoint

Accept an invoice ID, look it up in your database, build the HTML, and return the rendered PNG. The key is keeping the HTML generation in a separate function so it is easy to test and iterate on:

ts — app/invoice/[id]/route.ts
import { NextRequest } from 'next/server';
import { getInvoice } from '@/lib/db';
import { buildInvoiceHtml } from '@/lib/invoice-template';

export async function GET(
  _req: NextRequest,
  { params }: { params: { id: string } }
) {
  const invoice = await getInvoice(params.id);
  if (!invoice) {
    return new Response('Not found', { status: 404 });
  }

  const html = buildInvoiceHtml(invoice);

  const res = await fetch('https://renderpix.dev/v1/render', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-Api-Key': process.env.RENDERPIX_API_KEY!,
    },
    body: JSON.stringify({
      html,
      width: 794,
      height: 1123,
      format: 'png',
      deviceScaleFactor: 2, // 2× for print quality
    }),
  });

  if (!res.ok) {
    return new Response('Render failed', { status: 500 });
  }

  const filename = `invoice-${invoice.number}.png`;

  return new Response(await res.arrayBuffer(), {
    headers: {
      'Content-Type': 'image/png',
      'Content-Disposition': `attachment; filename="${filename}"`,
      'Cache-Control': 'private, no-store', // invoices are sensitive — do not cache
    },
  });
}

Use Cache-Control: private, no-store for invoice endpoints. Unlike OG images — which are the same for everyone — invoices contain financial data specific to a user. You do not want a CDN to serve one customer's invoice to another.

Rendering line items dynamically

The buildInvoiceHtml function maps your invoice data to an HTML string. Keep it straightforward — template literals work well for this:

ts — lib/invoice-template.ts
interface LineItem {
  description: string;
  detail?: string;
  qty: number;
  unitPrice: number;
}

interface Invoice {
  number: string;
  dueDate: string;
  from: { name: string; address: string };
  to:   { name: string; address: string };
  items: LineItem[];
  taxRate: number; // e.g. 0.20 for 20%
}

const fmt = (n: number) =>
  new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(n);

export function buildInvoiceHtml(inv: Invoice): string {
  const subtotal = inv.items.reduce((s, i) => s + i.qty * i.unitPrice, 0);
  const tax      = subtotal * inv.taxRate;
  const total    = subtotal + tax;

  const rows = inv.items.map(item => `
    <tr>
      <td>
        <div class="desc">${item.description}</div>
        ${item.detail ? `<div class="sub">${item.detail}</div>` : ''}
      </td>
      <td>${item.qty}</td>
      <td>${fmt(item.unitPrice)}</td>
      <td>${fmt(item.qty * item.unitPrice)}</td>
    </tr>`).join('');

  // full HTML template string with ${inv.*} values interpolated
  return `<!DOCTYPE html><html>...${rows}...</html>`;
}

Emailing the invoice

Most transactional email services accept base64-encoded attachments. Render the invoice to a buffer, encode it, and attach it alongside your payment confirmation email:

ts — send with Resend
import { Resend } from 'resend';

const resend = new Resend(process.env.RESEND_API_KEY);

const pngBuf = await renderInvoice(invoice); // returns Buffer

await resend.emails.send({
  from: '[email protected]',
  to: invoice.to.email,
  subject: `Invoice ${invoice.number} from Acme`,
  html: '<p>Thanks for your business. Your invoice is attached.</p>',
  attachments: [{
    filename: `invoice-${invoice.number}.png`,
    content: pngBuf.toString('base64'),
  }],
});

Build your invoice pipeline today.

Free tier includes 100 renders per month — more than enough to design, test, and ship your invoice flow.

← All articles API Reference →