DEV Community

jiahao luo
jiahao luo

Posted on

Every Invoice Tool Wants My Data. I Built One That Doesn't.

My girlfriend asked me to help her make an invoice. "You're good with computers."

I googled "free invoice generator." Here's what happened:

  • First result: signup required
  • Second: free trial expired, credit card
  • Third: works but PDF has a watermark across the whole thing
  • Fourth: actually decent but generates the PDF on their server — meaning they see my girlfriend's client name, her bank details, everything
  • Fifth: Google Docs template from Pinterest

I'm a frontend developer. Seven years. I sat there thinking: this is a form with like 10 fields, a table that does multiplication, and a PDF export. Why does any of this need a backend? Why does it need my email? Why is the PDF being generated on someone else's server?

It doesn't. So I built one where it doesn't.

ainvoicemaker.com. Next.js. Client-side PDF. Zero server storage. Your invoice data never leaves your browser. Open, fill, download.

Here's everything that went wrong.

jsPDF pretends Unicode doesn't exist

jsPDF ships with three fonts. Helvetica, Times, Courier. End of list.

User types Chinese → blank rectangles. Japanese → blank rectangles.

function hasCJK(text: string): boolean {
  return /[\u4e00-\u9fff\u3400-\u4dbf\u3040-\u30ff\uac00-\ud7af]/.test(text);
}
Enter fullscreen mode Exit fullscreen mode

Detect CJK → dynamically fetch NotoSansSC from Google Fonts. 10MB. Fine on fiber. Not fine on a phone in Jakarta.

AbortController, 8-second timeout. Font doesn't load? Helvetica it is. The CJK will be garbled but at least the tab doesn't freeze. I'll take "wrong" over "frozen" every time.

Image.naturalWidth returns 0 and I wasted two hours

Logo rendering. Need aspect ratio. Did the obvious:

const img = new Image();
img.src = dataUrl;
console.log(img.naturalWidth); // 0
Enter fullscreen mode Exit fullscreen mode

Zero. Always zero. Because .src assignment is async-ish and the browser hasn't decoded anything yet. I knew this conceptually. But when you're debugging at 11pm and the math looks right and the image is definitely there, you don't think "maybe the browser hasn't started loading this yet." You think "my ratio calculation is wrong" and you spend two hours refactoring perfectly correct math.

Fix:

const props = doc.getImageProperties(data.logoDataUrl);
const ratio = props.width / props.height;
Enter fullscreen mode Exit fullscreen mode

jsPDF has a built-in method that actually works. Two hours.

Intl.NumberFormat does everything and nobody talks about it

55 currencies. Was building a lookup table. Got to currency #8, thought "I refuse to do this manually."

new Intl.NumberFormat("ja-JP", { style: "currency", currency: "JPY" }).format(1234.5)
// "¥1,235"  — knows JPY has zero decimals

new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }).format(1234.5)
// "1.234,50 €"  — knows Germany uses comma decimals and puts € after
Enter fullscreen mode Exit fullscreen mode

The browser knows all of this. Has since 2016. I deleted my lookup table. Genuinely mad that I've seen a hundred "how to format currency in JavaScript" tutorials and none of them mention this API.

96% of my users were Playwright

The bad one.

Launched the app. Analytics: 175 sessions, 37 PDF downloads. Nice. Spent two days building dashboards, analyzing funnels.

Then I filtered the data. Removed HeadlessChrome (Playwright E2E — 226 tests, each firing analytics events). My own dev IPs. Cloud scrapers.

Real external humans: ~45. Real PDF downloads from non-developers: 1.

I'd been making product decisions based on my own test suite. If you run E2E tests that trigger your analytics endpoint, filter at collection time. Not at query time. I now drop all HeadlessChrome requests before they hit the database.

function shouldDrop(request: NextRequest): boolean {
  const ua = request.headers.get("user-agent") || "";
  if (ua.includes("HeadlessChrome")) return true;
  return false;
}
Enter fullscreen mode Exit fullscreen mode

Should've been line 1 of the analytics API. Wasn't.

localStorage: why I can't have nice things

No signup = no database. Returning users shouldn't re-enter their business name.

const KEYS = {
  BUSINESS_PROFILE: "inv_business",
  RECENT_CLIENTS: "inv_clients",
  INVOICE_SEQUENCE: "inv_sequence",
} as const;
Enter fullscreen mode Exit fullscreen mode

The one thing that works well: auto-incrementing invoice numbers (INV-0001, INV-0002...) that persist across sessions. Come back next month and it picks up where you left off. Feels like the tool remembers you. It doesn't. It's 47 bytes in localStorage. But it feels nice.

The problem nobody warns you about: user clears browser data, everything's gone. Switches to phone, nothing syncs. The real fix is "optional account" but then I'm adding signups, which is the thing I built this entire tool to avoid.

Still don't have a good answer for this one.

The dumb mistake

Code works fine. PDF generates. Tool does what it should.

My mistake was naming it "AI Invoice Generator" because AI sounds trendy. Nobody searches "AI invoice" — like 500 queries a month. "Invoice generator" gets 49,500. I optimized for vibes instead of for what people actually type into Google. Main keyword ranks position 72. That's page 8. Nobody scrolls to page 8.

Also built 7 document types, 55 currencies, 9 languages, and an auto-reminder system before having more than 2 visitors a day. Developer brain. Building is fun. Marketing is not. So you build.

ainvoicemaker.com — everything stays in your browser. No signup, no server storage. I just need humans to find it now.

Top comments (0)