A business that wants to reach customers everywhere — a website, an iPhone app, an Android app, sometimes a separate app per user role — is usually told it needs three teams: web, iOS, Android. Three codebases, three release cycles, and three subtly different ideas of what "an order" is. That's expensive, and worse, it's where bugs breed: the web says the order costs 250, the app says 240, and now you're debugging a discrepancy that should never have been possible.
I build the opposite way: one backend as the single source of truth, with Flutter for native and Next.js for web both talking to the same API. On Ajza, an automotive marketplace in Saudi Arabia, that meant one NestJS backend powering six client apps — three Next.js web surfaces and three native Flutter apps — with five distinct user roles. One backend, every device, one definition of the truth.
It only works if you're disciplined about a few things. Here are the ones that matter.
1. A uniform response envelope, decided once
If every endpoint returns a slightly different shape, every client reinvents parsing, and the bugs are endless. So the backend wraps every response in one envelope:
{ "success": true, "data": { }, "meta": { } }
On Ajza, a global ResponseInterceptor enforces this on every route, and a global ValidationPipe maps validation failures to per-field 422 errors in a predictable structure. The clients are built to match: the web's Axios client auto-unwraps data, attaches the auth token and locale headers, and handles FormData — once. The Flutter apps share a base_networking layer over Dio that does the same. Neither the React nor the Dart side parses responses ad hoc; they both speak one protocol.
Decide the envelope, the error shape, and the auth/locale headers once, at the boundary. Every client downstream gets simpler, and they all agree on what the server said.
2. Validation errors render inline — no client guesswork
A shared API only pays off if errors are shared too. Ajza's backend returns field-level 422 responses; the Flutter apps feed them straight into a CustomFormField + FormErrorHandler pair that surfaces the error under the offending field — no modal popups, no client-side re-implementation of the server's rules. A single AppSnackbar covers global errors.
The validation logic lives in exactly one place — the backend DTOs — and every client renders it consistently. Add a new rule server-side and all six apps respect it without a line of client change.
3. When apps share a backend but not a role, guard at the door
Ajza ships three separate native apps — Customer, Store, and Rider (the on-demand repair fleet) — each with its own bundle ID, each restricted to one role. They share a backend, so a signed-in user could technically authenticate against the wrong app.
Rather than leak endpoints or show a broken UI, each app runs a self-correcting role guard: an Ajza Store build that a customer signs into shows a WrongAppScreen that deep-links to the correct app's store listing. The backend stays the source of truth about who you are; the client just routes you to the right front door. Cross-role confusion is eliminated without three backends or three auth systems.
4. Real-time is one gateway, many rooms
Live features multiply fast — Ajza has four distinct chat domains (customer↔store, customer↔rep, RFQ negotiation, support) plus live map tracking for the repair fleet. The wrong move is a bespoke socket setup per feature. Instead, a single EventsGateway with room helpers (roomOrder, roomRepChat, roomStoreRequestChat, …) and one emitNewMessage(roomKey, payload) covers them all. The web and Flutter clients connect to the same /ws namespace.
This is also where shared infrastructure earns its keep in operations, not just code. Production WebSockets were dropping until I traced it to the origin Nginx and added the upgrade headers:
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
One fix, at one layer, restored real-time for every client at once — the dividend of a shared backend.
The same approach, repeatedly
Ajza is the largest example, but the pattern is consistent across my work:
| Platform | Web | Native | Shared backend |
|---|---|---|---|
| Ajza | 3 Next.js apps (landing, admin, partner) | 3 Flutter apps (customer, store, rider) | One NestJS API, 5 roles |
| Qooty | 2 Next.js 16 apps (public site, dashboard) | 1 Flutter app (Android/iOS/Web) | Node.js backends, shared models |
| Mudarris | 2 Next.js apps (parent portal, admin) | Flutter app (Android/iOS/macOS/Windows) | Supabase, shared Dart models |
A recurring accelerator: a shared types/models package. Ajza's Turborepo has @ajza/types, @ajza/api-client, and @ajza/i18n consumed by every web app; Mudarris has a Dart shared_models package consumed by every Flutter surface. The contract between client and server is defined once and imported everywhere, so a backend change that breaks a client breaks it at compile time — not in production.
The payoff
Building this way isn't about cleverness — it's about economics and correctness:
- One source of truth. An order, a price, a permission means the same thing on every device because there's one backend defining it.
- Fixes propagate. The Nginx WebSocket fix, a new validation rule, a changed response shape — done once, respected everywhere.
- You reach every customer without paying three teams. Native iOS and Android and web, from one backend and a shared contract.
The hard part isn't writing Flutter and Next.js — it's the discipline at the boundary: one envelope, one error shape, one real-time gateway, one set of shared types. Get that right and adding the next client app is a feature, not a project.