← all posts

Fatoora: one product, three architectures

An Arabic invoice maker built three times in three weeks: a localStorage-first static page with optional Supabase sync, then two Next.js versions on Neon and Clerk. What moved where, why, and which shortcuts were deliberate.

A lot of small businesses and freelancers in Jordan invoice by typing the amount into a WhatsApp message. Fatoora (فاتورة, Arabic for “invoice”) is a small tool for them: put in your logo, your saved services, the tax rate, and print a clean Arabic invoice in dinars. Free with a watermark, paid without one.

I built it three times. First as a static page with no backend at all, then the same afternoon as a Next.js app on Neon and Clerk, then about three weeks later as a second Next.js version rebuilt around how the customers actually work. The interface barely changed between them. Where data lives, who checks identity, and who decides whether someone has paid moved every time.

All three are prototypes. This post is about why each one looks the way it does, and the shortcuts I took on purpose.

In short:

1. Static pageVanilla JS pageapp.html + app.jsSupabase Author local SHA-256localStorageor Supabase + RLSUnlock codecompared in JS2. Next.js, same dayReact client pagesame featuresClerkproxy guards routesNeon PostgresJSONB via API routeLicence flaglink not wired up3. Next.js, rebuiltReact client pageWhatsApp, trial, limitsClerkchecked in each routeNeon PostgresJSONB, 1 MB capTrial + pro flagmanual activationall three: the paid status lives in data the browser can write
fig. 1 / one product, three architectures. the ui barely changed; auth, storage, and who checks what moved every time.

Version 1: a static page that works with no backend

The first version is three files that matter: a landing page, app.html, and app.js. You can open it straight from disk. Accounts, the business profile, the logo, saved items, and the last invoice are all saved in the browser’s localStorage.

If you paste a Supabase project URL and anon key into config.js, the same app switches to Supabase Auth for sign-up and stores the profile in a Postgres table instead. Both paths sit behind one pair of functions, so the rest of the app never knows which backend it is talking to:

// From app.js (trimmed)
async function backendSave(uid, data) {
  if (SUPA_OK()) {
    await supa.from("profiles").upsert({ id: uid, data, updated_at: new Date().toISOString() });
  } else {
    localStorage.setItem("fatoora_profile_" + uid, JSON.stringify(data));
  }
}
Save profiledebounced 600 msSupabase set?URL + anon keySupabase upsertRLS: owner rowslocalStoragethis browser only
fig. 2 / one save function, two backends. configuration decides, and localStorage is the default.

The whole profile is one JSONB column on one table. The anon key is public by design, because it ships to every browser, so the only thing protecting other people’s rows is row level security:

alter table profiles enable row level security;
create policy "owners read"   on profiles for select using (auth.uid()::text = id);
create policy "owners write"  on profiles for insert with check (auth.uid()::text = id);
create policy "owners update" on profiles for update using (auth.uid()::text = id);

In this design the browser talks to the database directly, and the database decides what each user may touch. There is no server of mine anywhere.

It came with a small smoke test that runs app.js under Node with stubbed localStorage, document, and crypto. It checks sign-up, the duplicate-email guard, a wrong password, a save and load round trip with Arabic text, and the invoice math: a line of two units at 75 with 16% tax must come to 174.00. No test framework, one file, and it catches the things that would actually embarrass me.

Version 2: same afternoon, Next.js, Neon, and Clerk

The static version made the operator do the backend setup: create a Supabase project, run SQL, paste keys. The second version made accounts and cloud storage part of the product instead of an option.

The stack is Next.js 16 with React 19, Clerk for authentication, and Neon serverless Postgres. The table is the same shape, renamed to use Clerk’s ID:

create table if not exists profiles (
  user_id    text primary key,
  data       jsonb not null default '{}'::jsonb,
  updated_at timestamptz not null default now()
);

The big architectural change is not the framework. It is that the browser no longer talks to the database. It calls /api/profile, and the route reads the user ID from Clerk’s session and uses it in the query. There is no row level security, because there is nothing to protect against: the database connection string never leaves the server, and the user ID in the query comes from the session, not the request body. Authorization moved from database policies into a few lines of route code.

Clerk’s middleware (in Next.js 16 it lives in proxy.ts) protected every route except the landing page and the sign-in pages. The database client is a lazy singleton, so the app builds before a connection string exists.

Moving the UI to React also fixed something quietly. Version 1 builds table rows with innerHTML; it escapes the line descriptions but not the saved item names. React escapes text by default, so that class of bug went away with the rewrite rather than through a fix.

Version 3: rebuilt around how customers actually work

Three weeks later I rebuilt the dashboard, and this time the product changed more than the plumbing.

The landing page of the first two versions said “stop sending invoices on WhatsApp”. Version 3 stopped fighting that habit. It is aimed at lawyers, offices, and freelancers in Jordan, and it works with WhatsApp instead of against it:

The money changed too. The first two versions sold a one-time $10 licence. Version 3 has a 7-day full trial, then three free invoices a month, and a subscription of 2 dinars a month or 20 a year, paid by CliQ bank transfer, with proof sent on WhatsApp and the account activated by hand.

On the server side, I followed Clerk’s resource-based guidance: the proxy only runs on the dashboard and the profile API, and the actual auth() checks live in the dashboard layout and in the route itself. The write path also got stricter:

PUT /api/profilethe whole profile as JSONClerk auth()no user id: 401Size checkover 1,000,000 characters: 413Parse and shape checkmust be a plain object, else 400Upsert JSONB by user_idinsert, on conflict update
fig. 3 / the write path in version 3. the user id comes from the session, never from the body.

The cap is there so a signed-in user cannot use their row as free blob storage. One small honesty note: the constant is called MAX_BYTES, but it compares the length of a JavaScript string, which counts characters. Arabic text takes two bytes per character in UTF-8, so it is really a character cap. Close enough for its job.

Arabic invoices and printing

All three versions set dir="rtl" on the root element and get most of right-to-left for free. The details are where it goes wrong:

There is no PDF library. The “download PDF” button calls window.print(), and the browser’s “save as PDF” does the rest, which handles Arabic shaping correctly because the browser already does. The only work is making sure only the invoice prints. Each version did it differently: version 1 hides the panels in an @media print block, version 2 uses Tailwind’s print: variants, and version 3 uses the visibility trick, which survives any layout around the invoice:

/* From version 3, globals.css */
@media print {
  body * { visibility: hidden; }
  #invoice-preview, #invoice-preview * { visibility: visible; }
  #invoice-preview { position: absolute; inset: 0; width: 100%; }
}

The free-tier watermark is part of the same preview, so it prints too: a large rotated “free copy” in versions 1 and 2, a small footer line in version 3.

The shortcuts, on purpose

I wrote these down in the setup notes as deliberate cuts, each with the point where it stops being acceptable. Here they are, with what the proper version looks like.

The licence check runs in the browser. In version 1, the unlock code is compared in app.js, which every visitor downloads, and it is one static code shared by all buyers. In Supabase mode the license flag sits in the user’s own profile row, which RLS lets them update. In version 3 the pro flag, the trial end date, and the monthly invoice count all live in the JSONB blob that the browser sends back whole on every save. A determined user can extend their own trial. My note said this was fine at low volume and to switch to per-user codes past about 20 sales.

The proper version separates content from entitlement:

todayproper versionBrowserPUT whole profileprofiles.data (JSONB)logo, office, items, invoicepro, trialEndsAt, countthe client can set these tooBrowserPayment webhookprofile JSONBcontent onlyentitlementsserver writes onlyAPI decides limitsreads entitlement, counts invoices
fig. 4 / left: what ships today. right: the proper version, where only the server can say who has paid.

The browser writes the profile and nothing else. A payment webhook, or an admin action for manual CliQ transfers, writes an entitlement record the browser cannot touch. The API strips entitlement fields from any client write, and counts invoices on the server when they are created instead of trusting a counter in the blob.

Local-mode passwords are hashed in the browser. Version 1’s no-backend mode stores a random salt and a single SHA-256 of salt plus password in localStorage. It stops casual reading, and nothing more: the profile data sits unencrypted in the same storage, and SHA-256 is a fast hash, not a password hash. My note called it demo-grade and said not to launch volume on it. The proper version is already in the code: Supabase Auth in version 1, Clerk in versions 2 and 3. If I ever handle passwords myself, it is server-side with a slow key-derivation function such as Argon2 or bcrypt.

The logo is a data URL inside JSONB. Versions 1 and 2 shrink the uploaded logo on a canvas to at most 400 pixels on its longest side and store it as a PNG data URL in the profile, which keeps the row small. Version 3 dropped the resize and stores the file as read, up to 1.5 MB. Writing this post, I noticed that does not fit: base64 makes a file about a third larger, so anything much over 700 KB passes the client check and then hits the server’s 413. And because the autosave does not look at the response status, the screen still says “saved”. The fix is to bring back the resize and check res.ok. The proper version puts logos in object storage and keeps only a URL in the profile.

One more, which I did not write down at the time: the whole profile is one blob, saved whole. Two open tabs overwrite each other, last write wins. For one person editing one invoice that is acceptable. With invoice history it would need real tables.

What I would do differently

  1. Decide who owns “paid” on day one. Every version so far lets the client hold it. Moving it to the server is the change that turns a watermark into a business.
  2. Keep content and entitlements in separate records. One JSONB blob was the right call for content, and the wrong place for the flag that decides what someone can do.
  3. Check the response, not just the absence of an exception. fetch does not throw on a 413, and that is how a too-big logo can look saved.
  4. Write down the ceilings. The shortcuts were fine because they were written down with the point where they stop being fine. The one that bit was the one nobody noted.
  5. Rewrite when the product changes, not when the framework does. Version 2 changed the stack and kept the product. Version 3 changed the product, and that was the rewrite that taught me something.

If you are building tools for Arabic-speaking small businesses, or arguing with yourself about how much backend a side project needs, I’m happy to compare notes. You can reach me through my site.