I've been building Invosmith, an invoicing tool for freelancers and small service businesses, on Next.js + Cloudflare Workers + D1. It's my first real product, and most of the interesting problems weren't the ones I expected. Here are six of them.
1. "Was my invoice opened?" — tracking without lying to the user
The most-requested thing from freelancers is simple: did the client even look at it?
The obvious answer is an email tracking pixel. I didn't rely on it. Apple Mail Privacy Protection and some corporate mail scanners fetch images automatically, so a pixel can report "opened" when nobody looked. That's worse than no tracking: the user stops chasing a client who never saw the invoice.
Instead, "Viewed" means the client opened the hosted invoice page from the email link. It's a narrower claim but an honest one, and the UI says exactly that: viewed on the invoice page, not read your email.
Lesson: an event that is sometimes wrong in a direction that costs the user money (they stop following up) is worse than a smaller, true signal.
2. Stripe Connect: the money must never touch my account
Clients pay invoices by card. The tempting design is to collect the money and pay the freelancer out. Don't: holding other people's money pulls you toward money-transmitter licensing.
Setup:
- Standard connected accounts, Stripe-hosted onboarding. Stripe handles KYC and risk for each freelancer.
- Direct charges: the payment is created on the freelancer's connected account, so funds land in their Stripe balance. My own account only ever sees my subscription revenue.
- My acceptance test is literal: if an invoice payment ever shows up in my own balance, the architecture is wrong.
Two webhook lessons:
- Keep two separate endpoints: one for my own billing (
checkout.session.completed, subscription events) and one scoped to connected accounts (account.updated,charge.refunded, disputes). Mixing them makes signature secrets and event routing confusing fast. - In the Stripe dashboard's event picker, grouped checkboxes can silently select sibling events. I ended up subscribed to legacy card/source events that never fire on a PaymentMethods integration. Pick events one by one and watch the count.
3. Payment links that are private but still work for the client
The client has no account, so the invoice link itself is the credential. That shapes everything:
-
Separate subdomain. Marketing lives on the main domain, the logged-in app on
app., and client-facing invoices onpay.. The reason is indexing: I want the marketing site (and eventually the app login) in Google, and I never want invoices there. I saw a well-known invoicing app put share links on the same host as its indexed login page, and some of those links ended up in search results. -
pay.is permanentlynoindex+ blocked in robots.txt. But noindex is the floor, not the security model. - Unguessable tokens. The link carries a long random token from a cryptographically secure generator, not an incrementing ID. Sequential IDs let anyone walk through other people's invoices (an IDOR bug).
- Every server read is scoped, on the owner side by account and on the client side by token, never by an ID from the URL alone.
-
No caching of private responses (
no-store), and session cookies never travel to the marketing site where third-party scripts run.
4. Analytics without exploding row counts
I wanted to know which pages and features actually drive signups. My first instinct was to log everything as rich events with lots of properties. On D1, where you pay attention to row counts and storage, that doesn't scale for a tiny product.
What worked:
- Analytics lives in a separate D1 database from the app, so a noisy events table can never slow down or bloat the database that holds invoices.
- A small, fixed event vocabulary registered in code; unregistered events are rejected, not stored.
- Properties are constrained to five parameter categories: [TODO: list your 5, e.g. page type / funnel step / feature / plan / source]. Everything else is dropped at the edge. That keeps the number of distinct combinations bounded, so "one row per event" stays small and every query can group by a known column.
5. Using the Search Console API to decide what to build next
The Search Console UI caps exports at 1,000 rows, so I pulled data through the API with a service account: impressions, clicks and position by query × page, daily.
What it told me: the head term ("invoice generator") is held by incumbents with a keyword difficulty around 65. A new domain won't rank for it in any reasonable time. But I was getting impressions on long-tail, vertical queries (contractor invoices, progress billing, specific document types) where difficulty is in single digits.
So I stopped aiming at the head term and put effort into vertical template pages and guides. Impressions without clicks became my to-do list: each one is a page that's almost ranking.
If I started again I'd do this keyword check before writing code, not after.
6. Bonus bug: D1 rejected a query that SQLite was happy with
Last night my dashboard 500'd in preview while 992 local tests passed. The "Needs you" panel used one query with seven SELECTs joined by UNION ALL. D1 returned too many terms in compound SELECT. Local SQLite allows far more terms; D1 didn't. The fix was splitting it into two smaller queries, making each dashboard card fail independently ("temporarily unavailable", never a fake 0), and adding a test that rejects large compound selects. Local SQLite is not D1.
Takeaways
- Prefer a small, true signal over a big, sometimes-false one.
- With payments, design so the money never touches you.
- Separate hosts when indexing rules conflict; noindex isn't security.
- Bound your analytics schema before it bounds you.
- Check keyword difficulty before you build, not after.
- Test against the real database runtime.
Invosmith lets you send invoices, quotes and credit notes, bill in stages, see when an invoice is viewed, send automatic reminders, and get paid by card straight to your own Stripe account. Invosmith takes no cut; you only pay Stripe's standard processing fee. Free invoice PDFs, no signup: invosmith.com.


Top comments (1)
Small catch on the pay. subdomain: blocking it in robots.txt stops Google from ever fetching those pages, so it never sees the noindex either. A share link posted somewhere public can still get indexed as a bare URL.
If you want the noindex to work, let it be crawled and send it as an X-Robots-Tag header. The unguessable tokens do the real job anyway.