# React Invoice Template: Line Items, VAT and Print View

> Build a react invoice template in Next.js: spreadsheet-style line items, discounts that keep the VAT exact, tax per rate and a print view that saves as a PDF.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Oct 16, 2026
- Category: [Build it: screens and apps](https://wingo-ui.com/blog/category/build)
- Reading time: 10 min
- Canonical: https://wingo-ui.com/blog/react-invoice-template

## TL;DR

A React invoice template is two views over one invoice object: an editor where the lines behave like a spreadsheet, and a print sheet with fixed paper colors that the browser turns into a PDF. Compute every total in one function that applies line discounts, shares a document discount across the lines before tax, and rounds the tax once per rate, then call that same function on the server before you save.

A typical react invoice template is a form with a table and one tax field under the subtotal. That works until the first real invoice: a discount on one line, another on the whole document, two VAT rates, and a client who wants a PDF that does not look like a screenshot of your app. This tutorial builds the version that survives those cases in Next.js: an invoice editor whose lines behave like a spreadsheet, totals with VAT per rate, and a print view that the browser saves as a clean A4 PDF. The editor is [Invoice Line Items](https://wingo-ui.com/components/invoice-line-items) from Wingo UI, the library we build, so we are not neutral; the totals and the print sheet are code you can read and copy.

## What does a React invoice template need?

It needs one invoice object and two views of it: an editor that changes it and a print sheet that only reads it. Whether you call the result a react invoice generator or a react invoice builder, the object holds the parties, the number, the dates, the currency, the lines and an optional document discount. The totals are never stored next to the lines in the browser state: they are derived from the lines on every render, in the browser for display and again on the server before saving.

```ts
// lib/invoice-types.ts
import type { InvoiceDocumentDiscount, InvoiceLine } from "@/components/ui/invoice-line-items";

export type Party = { name: string; address: string[]; vatId?: string };

export type Invoice = {
  number: string;
  issueDate: Date;
  dueDate: Date;
  currency: string;
  seller: Party;
  buyer: Party;
  lines: InvoiceLine[];
  discount: InvoiceDocumentDiscount | null;
  notes?: string;
};
```

`InvoiceLine` is `{ id, quantity, unitPrice, vatRate?, discount?, discountType? }` plus the printed fields `product`, `description`, `unit` and `vatCategory`. The rate is a fraction (0.2 for 20%), and `vatCategory` tells the 0% rows apart, so a zero rated line and an exempt line print as different rows.

## How do I build the invoice line items editor?

Install Invoice Line Items and pass it your products, your VAT rates and your currency. The lines are the part of a React invoice editor that people touch on every invoice, so they get the most keyboard work. The install brings the invoicing lib, the product combobox and the currency input with it:

```bash
npx wingo-ui@latest add invoice-line-items
```

The default VAT options are Romania's (21%, 11% and three 0% categories), because the component was built for e-Factura invoices. Pass your own list through `vatRates`. The example bills a UK client in pounds, with the three rates [GOV.UK lists](https://www.gov.uk/vat-rates) as of October 2026: 20% standard, 5% reduced and 0% zero rated.

```tsx
// app/(admin)/invoices/[id]/lines-editor.tsx
"use client";

import {
  InvoiceLineItems,
  type InvoiceDocumentDiscount,
  type InvoiceLine,
  type InvoiceProduct,
} from "@/components/ui/invoice-line-items";
import type { VatRateOption } from "@/lib/invoicing";

export const UK_VAT_RATES: VatRateOption[] = [
  { value: "20", rate: 0.2, category: "S", label: "20%", description: "Standard rate" },
  { value: "5", rate: 0.05, category: "S", label: "5%", description: "Reduced rate" },
  { value: "0-Z", rate: 0, category: "Z", label: "0%", description: "Zero rated" },
];

const PRODUCTS: InvoiceProduct[] = [
  { id: "layout", name: "Editorial design and layout", unit: "project", unitPrice: 2400, vatRate: 0.2 },
  { id: "copyedit", name: "Copyediting", unit: "hr", unitPrice: 45, vatRate: 0.2, keywords: ["proofreading"] },
  { id: "books", name: "Printed books", unit: "pcs", unitPrice: 6.5, vatRate: 0, sku: "BK-200" },
];

type Props = {
  lines: InvoiceLine[];
  discount: InvoiceDocumentDiscount | null;
  onLinesChange: (lines: InvoiceLine[]) => void;
  onDiscountChange: (discount: InvoiceDocumentDiscount | null) => void;
};

export function LinesEditor({ lines, discount, onLinesChange, onDiscountChange }: Props) {
  return (
    <InvoiceLineItems
      value={lines}
      onValueChange={(next) => onLinesChange(next)}
      products={PRODUCTS}
      vatRates={UK_VAT_RATES}
      defaultVatRate={0.2}
      units={["project", "hr", "day", "pcs"]}
      documentDiscount={discount}
      onDocumentDiscountChange={onDiscountChange}
      currency="GBP"
      locale="en-GB"
      name="invoice[lines]"
    />
  );
}
```

Picking a product fills its unit, price and VAT rate and keeps the quantity. Typing a name that is not in the catalog adds it as free text, with the price flagged until you enter one (`allowCustomProducts`, on by default). For a large catalog, swap `products` for `onSearchProducts`, an async search the combobox debounces.

A React invoice line items table built on react-hook-form's `useFieldArray` gets you rows you can add and remove, and quantity times price. The keyboard is what makes it feel like a spreadsheet. Tab moves through the cells in reading order, Escape in a number cell restores the value it had, Enter moves to the same cell on the next line, and Cmd or Ctrl + Enter adds a line from anywhere. Alt + Arrow Up or Down moves the current line, and a removed line leaves an undo row for five seconds.

> Live demo (Invoice line items): Tab through a line, change the quantity and the VAT rate, then remove a line and press Undo: the VAT rows under the table update on every keystroke. Try it at [Invoice line items](https://wingo-ui.com/components/invoice-line-items) and install it with `npx wingo-ui@latest add invoice-line-items`.

On a phone the same component shows each line as a card, and tapping one edits a copy in a bottom sheet that you keep with Save. The switch follows the component's own width (`layout="auto"` shows the table from 768px of container width), so a narrow side panel on a desktop gets the cards too. The [mobile-first React components guide](https://wingo-ui.com/blog/mobile-first-react-components) explains why we measure the container.

## How should discounts work on an invoice?

Line discounts come off first, then a document discount is shared across the lines before tax. A line discount, in percent or as an amount, comes off that line before anything else. A document discount comes off the subtotal after the line discounts, and it has to be shared across the lines before tax, in proportion to their value. If you subtract it from the grand total instead, the VAT printed for each rate no longer matches its taxable amount.

`computeInvoiceTotals` from the [invoicing lib](https://wingo-ui.com/components/invoicing) does both and rounds the VAT once per rate. Here is the example invoice, with 10% off the design line and 5% off the whole document:

```ts
import { computeInvoiceTotals } from "@/lib/invoicing";

const totals = computeInvoiceTotals(
  [
    { id: "l1", quantity: 1, unitPrice: 2400, vatRate: 0.2, discount: 10 },
    { id: "l2", quantity: 14, unitPrice: 45, vatRate: 0.2 },
    { id: "l3", quantity: 200, unitPrice: 6.5, vatRate: 0 },
  ],
  {
    discount: { type: "percent", value: 5 },
    categoryFor: (rate) => (rate > 0 ? "S" : "Z"),
  },
);

totals.gross; // 4330
totals.lineDiscount; // 240
totals.documentDiscount; // 204.5
totals.net; // 3885.5
totals.vatBreakdown;
// [{ rate: 0.2, category: "S", net: 2650.5, vat: 530.1, total: 3180.6, lineCount: 2 },
//  { rate: 0, category: "Z", net: 1235, vat: 0, total: 1235, lineCount: 1 }]
totals.total; // 4415.6
```

The 204.50 document discount is split 108.00, 31.50 and 65.00 across the three lines, the last line taking whatever is left so the cents add up. The 20% row is taxed on 2,650.50, which is exactly what the two 20% lines are worth after both discounts.

## Why compute VAT per rate instead of per line?

Because rounding each line and adding the results drifts. Three lines that each end in half a penny of VAT round up three times; the same base taxed once rounds up once. EN 16931, the European e-invoicing standard, defines the VAT of each rate as the rate's taxable amount times the rate, rounded to two decimals ([rule BR-CO-17](https://docs.peppol.eu/poacc/billing/3.0/rules/ubl-tc434/BR-CO-17/)), and the document totals have to add up from those rounded rows. Our post on [e-Factura VAT calculation](https://wingo-ui.com/blog/e-factura-vat-calculation-javascript) walks through the rule with a worked example and the validation errors it causes.

For the template this means one thing: the per-line VAT in `totals.lines` is informative, and every total you print, store or send comes from `vatBreakdown`.

One limit to know if you bill US sales tax: the VAT column formats rates with at most one decimal, so New York City's 8⅞% combined rate ([Publication 718](https://www.tax.ny.gov/pdf/publications/sales/pub718.pdf), effective March 1, 2025) would read 8.9%. Compute with the exact fraction, and print the rate yourself on the sheet.

## How do I make a clean print view for the invoice?

Build a separate sheet for paper and keep the editor off the printout. Inputs, drag handles and hover tints have no place on paper, and an editor printed in dark mode wastes toner. The sheet is plain HTML with fixed paper colors in both themes, which is the one place a Wingo UI screen uses hardcoded colors: the sheet stands for a printed page, so it ignores the theme.

```tsx
// app/(admin)/invoices/[id]/invoice-sheet.tsx
import { formatPrice } from "@/lib/commerce";
import { computeInvoiceTotals, formatVatRate } from "@/lib/invoicing";
import type { InvoiceLine } from "@/components/ui/invoice-line-items";
import type { Invoice } from "@/lib/invoice-types";

export function InvoiceSheet({ invoice, locale = "en-GB" }: { invoice: Invoice; locale?: string }) {
  const totals = computeInvoiceTotals(invoice.lines, {
    discount: invoice.discount,
    categoryFor: (rate, line) => (line as InvoiceLine).vatCategory ?? (rate > 0 ? "S" : "E"),
  });
  const money = (value: number) =>
    formatPrice(value, { currency: invoice.currency, locale, fractionDigits: 2 });
  const date = (value: Date) => value.toLocaleDateString(locale, { dateStyle: "medium" });

  return (
    <article className="mx-auto w-[210mm] bg-white p-[16mm] text-[12px] leading-relaxed text-neutral-900 [color-scheme:light] print:w-auto print:p-0">
      <header className="flex justify-between gap-8">
        <div>
          <p className="text-[18px] font-semibold">Invoice {invoice.number}</p>
          <p className="text-neutral-500">
            Issued {date(invoice.issueDate)}, due {date(invoice.dueDate)}
          </p>
        </div>
        <div className="text-end">
          <p className="font-semibold">{invoice.seller.name}</p>
          {invoice.seller.address.map((row) => <p key={row}>{row}</p>)}
          {invoice.seller.vatId && <p>VAT {invoice.seller.vatId}</p>}
        </div>
      </header>

      <section className="mt-8">
        <p className="text-[10px] font-semibold tracking-wider text-neutral-500 uppercase">Bill to</p>
        <p className="font-semibold">{invoice.buyer.name}</p>
        {invoice.buyer.address.map((row) => <p key={row}>{row}</p>)}
      </section>

      <table className="mt-8 w-full border-collapse">
        <thead className="border-b border-neutral-300 text-start text-neutral-500">
          <tr>
            <th className="py-2 text-start font-medium">Item</th>
            <th className="py-2 text-end font-medium">Qty</th>
            <th className="py-2 text-end font-medium">Price</th>
            <th className="py-2 text-end font-medium">VAT</th>
            <th className="py-2 text-end font-medium">Net</th>
          </tr>
        </thead>
        <tbody>
          {invoice.lines.map((line, i) => (
            <tr key={line.id} className="border-b border-neutral-200 align-top">
              <td className="py-2">
                {line.product}
                {line.description && <p className="text-neutral-500">{line.description}</p>}
              </td>
              <td className="py-2 text-end tabular-nums">{line.quantity} {line.unit}</td>
              <td className="py-2 text-end tabular-nums">{money(line.unitPrice)}</td>
              <td className="py-2 text-end tabular-nums">{formatVatRate(totals.lines[i].vatRate, locale)}</td>
              <td className="py-2 text-end tabular-nums">{money(totals.lines[i].net)}</td>
            </tr>
          ))}
        </tbody>
      </table>

      <dl className="ms-auto mt-6 w-64 tabular-nums [&>div]:flex [&>div]:justify-between [&>div]:py-0.5">
        <div><dt>Subtotal</dt><dd>{money(totals.gross)}</dd></div>
        {totals.discount > 0 && <div><dt>Discounts</dt><dd>-{money(totals.discount)}</dd></div>}
        {totals.vatBreakdown.map((row) => (
          <div key={`${row.rate}-${row.category}`} className="text-neutral-500">
            <dt>VAT {formatVatRate(row.rate, locale)} on {money(row.net)}</dt>
            <dd>{money(row.vat)}</dd>
          </div>
        ))}
        <div className="mt-1 border-t border-neutral-900 pt-1 font-semibold">
          <dt>Total</dt><dd>{money(totals.total)}</dd>
        </div>
      </dl>

      {invoice.notes && <p className="mt-10 text-neutral-500">{invoice.notes}</p>}
    </article>
  );
}
```

The net column shows each line after both discounts, so the lines add up to the taxable amounts in the VAT rows. The sheet has borders and no background fills, because browsers leave background colors out of a printout by default.

Then add the page rules to `app/globals.css`. `size` on `@page` is Baseline 2024 according to [MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/@page/size); use `letter` for clients in North America.

```css
@media print {
  @page {
    size: A4;
    margin: 16mm;
  }
  /* a long invoice repeats its column headers on every page and never splits a line */
  thead {
    display: table-header-group;
  }
  tr {
    break-inside: avoid;
  }
}
```

Chromium has repeated a `thead` at the top of each printed page since a June 2016 change that matched Firefox and Edge ([the commit](https://chromium.googlesource.com/chromium/src/+/81c0fc6d4e08e4e2bb4eb8a42747f21b13303616)), so the `display` rule only makes the intent explicit. Print a 40-line test invoice in every browser your clients use before a client does.

### How do I print only the invoice, not the app?

Mark everything else `print:hidden` and render the sheet `hidden print:block` while the editor is on screen. Your admin layout's sidebar and header need `print:hidden` too, since they sit outside the page component. With that, `window.print()` and the browser's Save as PDF give a PDF with only the document on it.

[react-to-print](https://github.com/MatthewHerbst/react-to-print) does the same through an iframe: `useReactToPrint({ contentRef })` copies the referenced node and the page's styles into a fresh document and prints that, so you can skip the `print:hidden` classes. Reach for `@react-pdf/renderer` only when the server must build PDFs without a person in the loop, such as attachments on scheduled emails; it renders its own layout primitives, so you maintain the invoice layout twice.

## What does the final invoice editor look like?

The editor and the sheet share one state object. Totals are never stored, only derived:

```tsx
// app/(admin)/invoices/[id]/invoice-editor.tsx
"use client";

import { useState } from "react";
import { Printer } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { Invoice } from "@/lib/invoice-types";
import { LinesEditor } from "./lines-editor";
import { InvoiceSheet } from "./invoice-sheet";
import { saveInvoice } from "./actions";

export function InvoiceEditor({ initial }: { initial: Invoice }) {
  const [invoice, setInvoice] = useState(initial);
  const [preview, setPreview] = useState(false);

  return (
    <form action={saveInvoice} className="flex flex-col gap-6">
      <input type="hidden" name="invoice[discount]" value={JSON.stringify(invoice.discount)} />
      <div className="flex flex-wrap items-center justify-end gap-2 print:hidden">
        <Button type="button" variant="ghost" onClick={() => setPreview((on) => !on)}>
          {preview ? "Edit" : "Preview"}
        </Button>
        <Button type="button" variant="outline" leftIcon={<Printer />} onClick={() => window.print()}>
          Download PDF
        </Button>
        <Button type="submit">Save</Button>
      </div>

      <div className={preview ? "hidden" : "print:hidden"}>
        <LinesEditor
          lines={invoice.lines}
          discount={invoice.discount}
          onLinesChange={(lines) => setInvoice((prev) => ({ ...prev, lines }))}
          onDiscountChange={(discount) => setInvoice((prev) => ({ ...prev, discount }))}
        />
      </div>

      <div className={preview ? "overflow-x-auto" : "hidden print:block"}>
        <InvoiceSheet invoice={invoice} />
      </div>
    </form>
  );
}
```

In preview the 210mm sheet scrolls sideways on a phone. If your clients check invoices on phones, measure the container with a `ResizeObserver` and scale the sheet with a CSS transform instead, which is what the Invoice Page block below does.

## How do I save the invoice without trusting the browser's totals?

Send the lines, never the totals, and compute again on the server with the same function. The `name` prop gives the editor a hidden input holding the lines as JSON, so a native form posts them to a Server Action:

```ts
// app/(admin)/invoices/[id]/actions.ts
"use server";

import { computeInvoiceTotals } from "@/lib/invoicing";
import type { InvoiceLine } from "@/components/ui/invoice-line-items";

export async function saveInvoice(form: FormData) {
  // validate both with zod in a real app: this is user input
  const lines = JSON.parse(String(form.get("invoice[lines]") ?? "[]")) as InvoiceLine[];
  const discount = JSON.parse(String(form.get("invoice[discount]") ?? "null"));
  // same options as the editor: a line without a rate falls back to defaultVatRate (the lib's default is 0.21)
  const { total } = computeInvoiceTotals(lines, { discount, defaultVatRate: 0.2 });
  // store lines, discount and total with your data layer; the sheet recomputes the rest from the lines
  console.log("invoice total", total);
}
```

The invoicing lib has no React imports, so it runs in a Server Action, a route handler or a queue worker unchanged.

## Can I start from a complete invoice page instead?

Yes, if you bill from Romania. The [Invoice Page](https://wingo-ui.com/components/invoice-page) block wraps the same line editor in a full screen: a client picker that searches by name, CUI or city, series and numbering that skip taken numbers, due date presets of 15, 30 and 45 days, a draft that saves itself 800 ms after the last change (Ctrl or Cmd + S saves at once), an A4 preview scaled to the screen on phones, a send dialog and the e-Factura switch. Its labels are English, but its fields follow Romanian B2B invoices, so for other countries the composition above is the better starting point.

> Live demo (Invoice Page): Change a quantity in the lines, then switch the header to Preview: the A4 sheet keeps paper colors in both themes and scales to fit a phone. Try it at [Invoice Page](https://wingo-ui.com/components/invoice-page) and install it with `npx wingo-ui@latest add invoice-page`.

For the rest of the back office around it, the [Next.js admin dashboard tutorial](https://wingo-ui.com/blog/nextjs-admin-dashboard-tutorial) builds the shell, the data tables and the settings, and more screens built step by step live in the [Build it category](https://wingo-ui.com/blog/category/build).

## Next step

Install the editor with `npx wingo-ui@latest add invoice-line-items`, paste the sheet and the print rules, and print a long test invoice in both themes. Invoice Line Items, the invoicing lib and the Invoice Page are part of [Wingo UI Pro](https://wingo-ui.com/pricing).

## Components in this post

- [Invoice Page](https://wingo-ui.com/components/invoice-page) (Pro): A React invoice page to edit and view Romanian B2B invoices: client, series, VAT lines, totals, an A4 preview, sending and e-Factura. Install: `npx wingo-ui@latest add invoice-page`
- [Invoice line items](https://wingo-ui.com/components/invoice-line-items) (Pro): A React invoice line items editor that works like a small spreadsheet, with VAT per rate computed the e-Factura way. Install: `npx wingo-ui@latest add invoice-line-items`
- [Invoicing](https://wingo-ui.com/components/invoicing) (Pro): Invoice math for React billing apps: VAT per rate the way e-Factura checks it, Romanian VAT rates, and CUI, IBAN and CNP validation. Install: `npx wingo-ui@latest add invoicing`

## FAQ

### How do I make a printable invoice in React?

Render the invoice as a plain HTML sheet with fixed white paper colors, hide the editor and the app shell with Tailwind's print:hidden, set the paper size with @page, and call window.print(). The browser's Save as PDF option then produces a PDF that contains only the sheet.

### Should I use react-to-print or @react-pdf/renderer for invoices?

Use the browser's print, directly or through react-to-print, when the invoice already exists as HTML on screen and a person prints it. Use @react-pdf/renderer when the server has to generate PDFs on its own, for example to attach them to emails, and accept that its layout primitives are separate from your web components.

### How do I apply a discount to the whole invoice when lines have different tax rates?

Share the document discount across the lines in proportion to their value after their own discounts, before any tax, and give the rounding remainder to the last line. Each rate's taxable amount then drops by its share, so the tax per rate stays exact.

### Should invoice tax be calculated per line or per rate?

Per rate for VAT invoices: add the taxable amounts of all lines at a rate and round the tax once. EN 16931, the European e-invoicing standard, defines the VAT of each rate that way, and per-line tax can then stay on the document as information.

### Is there a free React invoice template?

The print sheet, the print CSS and the rounding rules in this tutorial are yours to copy into any React app. The Wingo UI pieces it imports, Invoice Line Items, the invoicing lib with computeInvoiceTotals and the Invoice Page block, are part of the Pro plan.

---

Source: https://wingo-ui.com/blog/react-invoice-template
