Dev.to WebDev 🛠 Dev 👁 0 📖 3 min read

I built a VAT/GST invoice generator with zero backend — here's how

Most invoice tools want you to create an account, they upload your client data to their servers, and they cost real money to run. I wanted the opposite: a tool that runs entirely in the browser, keeps invoice data on the

Most invoice tools want you to create an account, they upload your client data to their servers, and they cost real money to run. I wanted the opposite: a tool that runs entirely in the browser, keeps invoice data on the user's device, and costs me close to nothing to operate.

It turned into Crossbill — a free VAT/GST invoice generator. Here's the architecture and the genuinely interesting bits, including the one that surprised me: even the paid tier needs no backend.

The constraint: no server, at all

The whole thing is a static site — Next.js with output: 'export' — deployed on Cloudflare Pages. There's no server, no database, and no auth. Editing, tax logic, and PDF generation all happen client-side, and the invoice data (clients, rates, amounts) lives in localStorage. It never leaves the device.

The nice side effect: marginal cost is basically zero, so I can leave the core free forever without it costing me per-user.

Money math without floating point

The first thing that bites you when you sum line items is floating point:

0.1 + 0.2 === 0.3 // false

Do that across a dozen invoice lines with tax and discounts and you'll be a cent off in ways that look unprofessional. So all the math is done in integer cents and only converted back at the edge:

export const toCents = (n) => Math.round((Number(n) || 0) * 100);
export const fromCents = (c) => c / 100;

// quantity can be fractional (e.g. 1.5 hours); round the product to whole cents
export const lineTotalCents = (qty, rate) =>
  Math.round((Number(qty) || 0) * (Number(rate) || 0) * 100);

Formatting is left to Intl.NumberFormat so 25+ currencies get correct symbols, grouping and decimal digits (JPY has 0, most have 2) for free.

Generating the PDF in the browser

No headless Chrome, no server render — jsPDF plus jspdf-autotable draw the invoice directly. One gotcha: some currency symbols render as tofu boxes in the default PDF fonts, so inside the PDF I use the unambiguous ISO-code form (EUR 1,200.00) instead of the glyph — which is arguably clearer for cross-border invoices anyway.

The actual hard part: tax compliance, as data

Here's the thing — the arithmetic isn't the hard part. Any tool can multiply a subtotal by a rate. The part cross-border freelancers actually fear getting wrong is the required fields and wording per jurisdiction.

So each jurisdiction is just a plain config object, and switching it rewrites both the form and the document:

{
  id: "eu-rc",
  docTitle: "Invoice (Reverse Charge)",
  taxLabel: "VAT",
  defaultRate: 0,
  sellerTaxIdLabel: "Your VAT ID",
  buyerTaxIdLabel: "Customer VAT ID (VIES)",
  buyerTaxIdRequired: true,
  complianceNote:
    "Reverse charge: VAT to be accounted for by the recipient " +
    "(Article 196, Council Directive 2006/112/EC).",
}

Add AU GST (10%, "Tax invoice", ABN, "Total price includes GST"), UK VAT (20%, sequential numbering, VAT reg number), Canada GST/HST, US 1099, and the German Kleinunternehmer §19 UStG statement, and the "differentiator" is really just well-researched static data. Which is a nice reminder that a moat can be content, not code.

Monetization with no backend either

This is the part I didn't expect to work. I assumed the paid tier would force me to stand up a server. It didn't.

The Pro unlock is a one-time Lemon Squeezy purchase that issues a license key. You validate that key client-side against Lemon Squeezy's public endpoint — no secret key, and it's CORS-friendly, so the browser can call it directly:

const res = await fetch("https://api.lemonsqueezy.com/v1/licenses/validate", {
  method: "POST",
  headers: { "Content-Type": "application/json", Accept: "application/json" },
  body: JSON.stringify({ license_key: key }),
});
const data = await res.json();
if (data.valid) unlockPro();

On success the license is stored in localStorage and the Pro features light up. No backend of mine anywhere in the flow.

Tradeoffs I accepted

I'd rather be upfront about these than pretend they don't exist:

  • The license check is client-side, so it's bypassable. For a $29 impulse tool, a server to stop piracy would cost more (in money and in the zero-backend promise) than the piracy it prevents. Accepted.
  • localStorage means switching devices or clearing your browser loses your data. A JSON export/import gives you a copy you own.
  • The tax presets are a convenience sourced from official guidance, not tax advice. There's a disclaimer in the UI and the PDF.

Result

It's live at getcrossbill.com — free, no signup, runs in your browser. If you invoice under a jurisdiction I've modelled wrong, or one I should add, I'd genuinely like to know.

Happy to answer questions about the client-side/no-backend approach in the comments.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.