Skip to Content
TutorialsInvoice Generator

Tutorial: Invoice Generator

Build the classic flow: a Download PDF button on an invoice page that renders a real, branded invoice. This mirrors the open-source demo at github.com/Reforgio/reforgio-demo , which you can run to see the finished result.

Create the invoice template

In the dashboard , create a template (format: PDF) with:

  • an HTML section for the header — company and customer details, using variables like {{invoice_number}}, {{date}}, {{company_name}}, {{customer_name}};
  • a table section with dataKey: items and columns description (string), quantity (number), unit_price (currency), total (currency);
  • an HTML section for the totals — {{subtotal}}, {{tax_rate}}, {{tax_amount}}, {{total_amount}}.

Publish it and note the template ID (visible in the template’s URL).

Write the server action

The action maps your domain objects (invoice, customer) to the template’s flat variable payload:

app/actions.ts
'use server' import { ReforgioServer, ReforgioError } from '@reforgio/sdk-core/server' const reforgio = new ReforgioServer(process.env.REFORGIO_SECRET_KEY!) export type RenderResult = | { success: true; downloadUrl: string; renderTimeMs: number } | { success: false; error: string } export interface Invoice { number: string date: string dueDate: string status: string items: Array<{ description: string; quantity: number; unitPrice: number }> } export interface Customer { name: string email: string address: string } export async function exportInvoice(invoice: Invoice, customer: Customer): Promise<RenderResult> { const subtotal = invoice.items.reduce((sum, i) => sum + i.quantity * i.unitPrice, 0) const taxRate = 0.23 const taxAmount = subtotal * taxRate try { const doc = await reforgio.render(process.env.REFORGIO_TEMPLATE_INVOICE_ID!, { invoice_number: invoice.number, date: invoice.date, due_date: invoice.dueDate, status: invoice.status, company_name: 'ACME Corp', customer_name: customer.name, customer_email: customer.email, customer_address: customer.address, items: invoice.items.map((item) => ({ description: item.description, quantity: item.quantity, unit_price: item.unitPrice, total: item.quantity * item.unitPrice, })), subtotal, tax_rate: taxRate * 100, tax_amount: taxAmount, total_amount: subtotal + taxAmount, }) return { success: true, downloadUrl: doc.downloadUrl, renderTimeMs: doc.renderTimeMs } } catch (err) { if (err instanceof ReforgioError) { return { success: false, error: err.message } } throw err } }

Note the mapping pattern: computed values (total per row, subtotal, tax_amount) are calculated in code and passed as plain variables — templates receive ready-to-print data.

Add the download button

app/invoices/[id]/export-button.tsx
'use client' import { useState, useTransition } from 'react' import { exportInvoice, type Invoice, type Customer, type RenderResult } from '../../actions' export function InvoiceExportButton({ invoice, customer }: { invoice: Invoice; customer: Customer }) { const [isPending, startTransition] = useTransition() const [error, setError] = useState<string | null>(null) function handleClick() { setError(null) startTransition(async () => { const result: RenderResult = await exportInvoice(invoice, customer) if (result.success) { window.open(result.downloadUrl) } else { setError(result.error) } }) } return ( <div> <button onClick={handleClick} disabled={isPending}> {isPending ? 'Generating…' : 'Download PDF'} </button> {error && <p role="alert">Export failed: {error}</p>} </div> ) }

Run it

Set the env vars and click the button:

REFORGIO_SECRET_KEY=sk_live_... REFORGIO_TEMPLATE_INVOICE_ID=<your template id>

The PDF opens in a new tab from the presigned downloadUrl.

Variations

  • XLSX export — pass 'xlsx' as the third argument to render (template needs the XLSX format and a table section). The demo’s overdue invoices export does exactly this.
  • Receipts, certificates, reports — same pattern, different template + payload; the demo repo  implements all of them in app/actions.ts.
  • Client-side preview — render the invoice in an <iframe> as the user edits, using frontend rendering with useRender.

Rendering from a server action keeps your secret key on the server and lets you derive totals from trusted data. Use frontend rendering when you want live previews without a round-trip through your own backend.