DineEasy
One Next.js app. Four audiences, four languages, real money. Built solo.
SaaS
Client Work
Role
Sole Developer
Timeline
8 weeks
team
Just me on the build. The client owned product direction and the restaurant relationships.
platform
Web

The Real Problem
A Swiss client came to me to build a digital ordering platform for restaurants. The restaurants they wanted to serve had two bad options on the market. Build something custom they couldn't afford, or join a marketplace that put its own name on the customer's receipt, held the money before passing it on, and owned the diner relationship the restaurant had spent years earning.
The market also had a constraint most ordering products treat as an afterthought. Switzerland is multilingual. A menu that exists only in German is broken for a real share of the people sitting at the table. German, French, Italian, and English are not a nice-to-have here, they are table stakes.
So the brief was not "build a menu app." It was: a restaurant should take a card payment from a diner's phone where the restaurant is the merchant, the funds never touch a middleman, the diner sees the restaurant's name on their statement, and the whole thing works in four languages without the owner translating anything by hand. That is a payments problem, an internationalization problem, and a multi-tenancy problem wearing a trench coat, and it had to ship as one product a non-technical restaurant owner could run.

Finding the Fix
I made four architectural decisions early that the whole product hangs off.
One Next.js app, routing by host. Marketing, auth, dashboard, and the diner menu all live in a single codebase. A single routing layer, in one request pass, does locale enforcement, refreshes the session, and rewrites restaurant.client-domain to that restaurant's internal menu route. One app to reason about, one deploy, no service sprawl for a solo developer to babysit.
Stripe Connect direct charges, so the restaurant is the merchant. Every diner payment is a direct charge on the restaurant's own connected Stripe account, not a charge through the platform. The diner's statement reads the restaurant's name. Disputes land on the restaurant's dashboard. The platform never custodies a franc. The client's only cut is a flat 2.5% application fee that Stripe routes separately. This one decision answered the compliance, trust, and liability questions at once.
Multilingual content as data, not duplicated rows. Each owner-authored string carries its translations plus a small provenance record: which language the owner actually wrote, which were auto-filled by machine translation, and which the owner corrected by hand. Auto-translation fills gaps via DeepL but never overwrites anything the owner verified. Owner writes once, diners read in their language, owner edits are always respected.
Three caching layers that stay honest. A diner menu is read constantly and changed rarely, so it is heavily cached. But when an owner edits a dish, they must see it immediately, not in five minutes. So it is layered: an in-app cache for instant read-your-own-writes after an edit, a shared cache for high-traffic public menus, and Postgres as the source of truth. Every mutation invalidates all layers through one set of helpers, so a stale menu is never something I debug case by case.

What Actually Happened
he build held up, but payments taught me where the real difficulty was, and it was not the happy path.
The happy path is easy: diner pays, webhook confirms, order hits the kitchen. The hard part is everything in between. A payment that succeeds at Stripe while the follow-up write to the database fails. A diner who pays sixty-one seconds after a cleanup job already cancelled their abandoned order. A webhook delivered twice. Money is real, so "mostly works" is not a finish line, and on a client engagement it is not a defensible one either.
So the order lifecycle is built defensively. State transitions are compare-and-swap, so two devices and a webhook racing on one order cannot corrupt it. Webhooks are idempotent against an events table, so a duplicate delivery is a no-op. And a reconciliation job periodically scans confirmed payments and repairs the two cases that matter: a payment that succeeded but whose order record never got linked, and a payment that landed just after the order was auto-cancelled. The second case literally resurrects the order so the kitchen still gets it and the restaurant still gets paid.

What Changed
The decision that made everything else simpler was making the platform a pure facilitator instead of an intermediary.
It is one architectural choice: direct charges on the restaurant's account instead of routing money through the platform. Technically it is a single option on how a payment is created. But it removed an entire class of problems I would otherwise have fought for the length of the project. No funds to custody. No payouts to reconcile. No platform name on a diner's statement triggering a confused chargeback. Disputes go to the party who served the food. The client's compliance exposure dropped to near zero.
The lesson I keep: the highest-leverage decision is often not the most code. It is the one that deletes whole categories of future work before they exist. On client work, that is also the one that protects the client long after I have handed it over.metric.

What I Had to Work With
I was the only developer on this. Frontend, backend, database, payments, infrastructure, the marketing site, all of it. The client set direction and priorities. I made every technical call, which shaped the architecture toward things one person could own and keep correct.
One product, four completely different audiences. A marketing site for prospects, an auth flow, an owner dashboard for running a restaurant, and a diner-facing menu opened by scanning a QR code at a table. Different users, different performance needs, different security models. Four separate apps would have been four things for one person to maintain.
Real money, so correctness was not optional. A dropped payment means a restaurant served food it did not get paid for, or a diner charged for an order that never reached the kitchen. On a client project with the client's reputation attached, the failure modes had to be designed for, not patched later.
Swiss compliance shaped the payment model. If the platform held funds or appeared as the merchant, that pulls in money-transmission and liability questions neither the client nor the restaurants wanted to own.
Multilingual from day one, not bolted on. Owners write a menu once, in their language. Diners read it in theirs. The owner cannot be expected to translate every dish into three other languages by hand.

What I'd Do Differently
I would design the payment failure modes before the happy path, not after. The reconciliation job exists because I found the edge cases in production thinking instead of in the original design. The system is correct now, but I bought that correctness with rework I could have avoided by treating the unhappy paths as the actual specification from the start.
I would also surface hard numbers earlier. I leaned on reasoning about where the product would strain. Instrumenting the real order flow sooner would have pointed me at the payment-settlement gap before I had to reason my way to it.
What I Learned
Constraints are an architecture tool. Being the sole developer on a client project is what forced the single-app, host-routed design and the pure-facilitator payment model. With a team I might have allowed more moving parts. Alone, simplicity was not a preference, it was the only way the thing stayed correct and handed over cleanly.
Building for a client raises the bar on the boring parts. Idempotent webhooks, compare-and-swap state, reconciliation jobs. None of it is glamorous, none of it shows in a screenshot, and all of it is the difference between a demo and something a Swiss restaurant can trust with its revenue every night.
The most important work was the invisible work. The reconciliation job and the merchant-of-record decision are the parts the client will never see and never have to think about. That was the point.