Generate PDF invoices in Node.js and Next.js

Map your order to Quire's typed InvoiceData object and call quire.render(invoice, { template: "invoice" }) from a route handler, then return pdf.bytes with a Content-Type of application/pdf. There's no HTML to write and no headless browser to run; the invoice template computes subtotal, tax and total for you.

You’ll build GET /invoices/:id/pdf, which loads an order, renders it with Quire’s invoice template and streams the PDF back to the browser. It takes three files and one dependency.

1. Install and set your key

npm install quirepdf
npx quirepdf-cli login [email protected]   # prints and saves a qk_live_ key

Add the key to .env.local:

QUIRE_API_KEY=qk_live_...

The SDK reads QUIRE_API_KEY from the environment. Keep it server-side: never prefix it with NEXT_PUBLIC_.

2. Map your order to InvoiceData

// lib/invoice-data.ts
import type { InvoiceData } from "quirepdf";

// The shape of an order in your database. Adjust to match yours.
export interface Order {
  id: string;
  createdAt: Date;
  paid: boolean;
  customer: { name: string; email: string; address: string[] };
  lines: { name: string; quantity: number; unitPriceCents: number }[];
}

const formatDate = (d: Date) => d.toLocaleDateString("en-US", { day: "numeric", month: "short", year: "numeric" });

export function toInvoiceData(order: Order): InvoiceData {
  const due = new Date(order.createdAt);
  due.setDate(due.getDate() + 14);
  return {
    number: `INV-${order.id}`,
    status: order.paid ? "paid" : "due",
    issued: formatDate(order.createdAt),
    due: formatDate(due),
    currency: "$",
    accent: "#3b5bdb",
    seller: {
      name: "Northwind Studio",
      address: ["221 Market Street, Suite 400", "San Francisco, CA 94105"],
      email: "[email protected]",
    },
    customer: order.customer,
    items: order.lines.map((line) => ({
      description: line.name,
      qty: line.quantity,
      unit_price: line.unitPriceCents / 100,
    })),
    tax: { label: "Sales tax (8.625%)", rate: 0.08625 },
    payment: { method: "Card or bank transfer", link: `pay.northwind.studio/${order.id}` },
  };
}

InvoiceData is generated from the invoice template’s JSON Schema, so TypeScript catches a missing due date, a string qty or a status of "late" before you deploy. Your editor shows each field’s description.

A few things the template expects:

  • Dates are strings, already formatted the way you want them printed.
  • Prices are numbers in currency units, not cents. currency is the symbol to print before them.
  • Rates are fractions. With rate: 0.08625, the template computes the tax amount and prints your label next to it.
  • Send line items, not totals. The template computes the subtotal, discount, tax and amount due from qty, unit_price and the rates.

3. Add the route handler

// app/invoices/[id]/pdf/route.ts
import { Quire, QuireError } from "quirepdf";
import { getOrder } from "@/lib/orders"; // your database query
import { toInvoiceData } from "@/lib/invoice-data";

const quire = new Quire(); // reads QUIRE_API_KEY

export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const order = await getOrder(id);
  if (!order) return new Response("Not found", { status: 404 });

  const invoice = toInvoiceData(order);
  try {
    const pdf = await quire.render(invoice, { template: "invoice" });
    return new Response(pdf.bytes.slice(), {
      headers: {
        "Content-Type": "application/pdf",
        "Content-Disposition": `inline; filename="${invoice.number}.pdf"`,
        "Cache-Control": "private, no-store",
      },
    });
  } catch (err) {
    if (err instanceof QuireError) {
      console.error(`Quire ${err.type} (${err.status}): ${err.message}`);
      return Response.json({ error: err.type, message: err.summary, fields: err.fields }, { status: 502 });
    }
    throw err;
  }
}

getOrder(id) is your own data access and should return an Order or null. Check that the signed-in user may see this order before rendering it.

Open http://localhost:3000/invoices/1042/pdf and the browser shows the invoice. Change inline to attachment to download it instead.

pdf.bytes.slice() passes a copy of the bytes. Recent TypeScript versions don’t accept the SDK’s Uint8Array as a Response body directly; the copy has the exact type they expect.

What happens when data is wrong

If an order produces invalid data, for example a customer with no address and a line with no name, the API rejects it before rendering and lists every problem. The handler above logs:

Quire invalid_data (422): data has 2 problems
  - customer.address is required
  - items[1].description is required

Nothing counts against your quota when a render fails. To check data without rendering, for example in a test, call quire.validate(invoice, { template: "invoice" }). It’s free and returns { valid, errors }.

Outside Next.js

The route handler only uses the standard Request and Response objects. In Express, send the bytes with res.send:

import express from "express";
import { Quire } from "quirepdf";
import { getOrder } from "./orders.js";
import { toInvoiceData } from "./invoice-data.js";

const app = express();
const quire = new Quire();

app.get("/invoices/:id/pdf", async (req, res, next) => {
  try {
    const order = await getOrder(req.params.id);
    if (!order) return res.sendStatus(404);
    const pdf = await quire.render(toInvoiceData(order), { template: "invoice" });
    res.type("application/pdf").send(Buffer.from(pdf.bytes));
  } catch (err) {
    next(err);
  }
});

app.listen(3000);

Next steps

  • Email the PDF as an attachment: pdf.bytes is the whole file.
  • Show a preview image with { format: "png" }; see the SDK reference.
  • See every invoice field, including discount, payment.bank and notes, in the invoice template.
  • Rendering receipts or credit notes too? They work the same way with ReceiptData and CreditNoteData.