One Versioned API for Web, iOS and Android: What We Learned Building ManOps

One Versioned API for Web, iOS and Android: What We Learned Building ManOps
📌

Executive Summary & Key Takeaways

When you build a web app and native iOS and Android apps for the same product, you face a choice early: one shared backend or separate backends per client. The shared backend sounds obviously correct. In practice it creates coordination problems that separate backends avoid - until the separate backends diverge and you are maintaining three versions of your data model. We built ManOps, our internal team operations product, with a single versioned REST API serving a Next.js web app and Flutter iOS and Android apps. Here is what we got right, what surprised us, and what we would do differently.

Table of Contents
  1. What ManOps Looks Like
  2. Why a Written Contract Mattered
  3. Versioning: What /api/v1 Actually Bought Us
  4. The Response Shape Problem
  5. Authentication Across Three Clients
  6. The 44 Migration Problem
  7. What We Would Do Differently
  8. Common questions
  9. Sources

When you build a web app and native iOS and Android apps for the same product, you face a choice early: one shared backend or separate backends per client. The shared backend sounds obviously correct. In practice it creates coordination problems that separate backends avoid - until the separate backends diverge and you are maintaining three versions of your data model. We built ManOps, our internal team operations product, with a single versioned REST API serving a Next.js web app and Flutter iOS and Android apps. Here is what we got right, what surprised us, and what we would do differently.

What ManOps Looks Like

ManOps is Zovintra's own operations tool: projects, tasks, calendar, team chat, calls, a credential vault, and insights, all in one workspace. It runs as a web app (Next.js, deployed on Vercel) and as native phone apps (Flutter, for iOS and Android). All three clients connect to the same backend: Next.js API routes at `/api/v1`, documented in an OpenAPI spec with 135 paths and 205 operations.

The database is Supabase (Postgres), with 44 migration files in the repository. The test suite has 3,298 Vitest tests for the web backend and 2,266 Flutter tests for the native apps. The web app and Flutter apps share no code directly - they share the API contract.

Why a Written Contract Mattered

We generate the /api/v1 OpenAPI document from the route specs rather than maintaining it by hand, so it cannot drift from the handlers. The spec was generated on 30 September 2026, after the web app routes already existed, and the Flutter app was built against it from the start. This order - web app first, spec generated from routes, Flutter app built against the spec - is the more common path in practice, even if contract-first is the theory.

Having a documented contract paid off when Flutter development started. The native app team had a stable, documented interface to build against. API changes required updating the route definitions first, which regenerated the spec and made breaking changes visible before they happened rather than after. Without that discipline, native apps tend to fall behind the web app's API version because the web app team does not feel the cost of an undocumented change until the native team reports a broken endpoint.

If you are building a product that will eventually have mobile clients, design your API for mobile from the start. The cost of retrofitting versioning and mobile-appropriate response shapes onto a web-optimised API is significant.

Versioning: What /api/v1 Actually Bought Us

All ManOps API paths start with `/api/v1`. At the time this felt like engineering overhead for a product that only had one client. It turned out to be the right call.

When the Flutter app was added as a second client, versioning gave us a clear compatibility boundary. Today /api/v1 is routed to the same handlers as the web app's API; the plan is that when a route needs a different shape for native clients, a dedicated v1 handler takes precedence, so the web app can move on without breaking the native app. Without explicit versioning, every API change is implicitly a breaking change for any client that has not been updated.

Mobile apps have a deployment constraint that web apps do not: users may not update the app immediately. A user on iOS can run a version of the ManOps app that is several weeks old while the web app and backend are at the latest version. The v1 prefix gives us a stable contract to plan deprecations around, and the backend can check an `X-Client` version header and refuse clients below a configured minimum version for each platform.

The Response Shape Problem

Web and mobile clients want different things from the same data. A web dashboard can afford to fetch a project object, then separately fetch its task count, member list, and activity feed, because the network is fast and the browser can handle parallel requests. A mobile app on a cellular connection needs that data in one response.

We solved this with composite endpoints for the native app's most-used views. Rather than exposing only normalised, resource-centric endpoints, we added view-specific endpoints that aggregate the data a screen needs. Endpoints such as `/api/v1/me/work` and `/api/v1/nav/counts` are on v1 and documented in the spec. The difference is that they are shaped around the mobile use case, not the database schema.

The risk of composite endpoints is that they embed display logic in the backend. If the mobile UI changes, the composite endpoint may need to change too. We accept that trade-off because the alternative - many small requests on a mobile network - tends to hurt responsiveness.

Authentication Across Three Clients

We use Firebase Auth for authentication across all three ManOps clients. The web app and the Flutter app both sign in through Firebase, then send the Firebase ID token in the Authorization header to the backend. The backend verifies the token with the Firebase Admin SDK on every request.

This single auth flow across all clients was the right call. The alternative - separate auth systems for web and mobile - creates divergent session handling, separate token stores, and a support surface where a user's account may behave differently depending on which client they use.

The complexity is in token refresh. Firebase ID tokens are short lived and last for an hour. Web apps handle refresh silently through the Firebase SDK in the browser. Native apps need to handle refresh in the background, including during calls and real-time connections where the token may expire mid-session. In the Flutter app's API client, a 401 triggers one forced token refresh and a retry before the session is treated as ended.

The 44 Migration Problem

ManOps has 44 database migration files in the repository. Every schema change is a migration. This is the only way to keep the database schema reproducible across environments and to understand what changed and when.

The discipline that matters here is: no manual changes to the database schema in production. Every change goes through a migration file. This sounds obvious but is regularly violated in practice, especially in early-stage products where moving quickly means running a quick `ALTER TABLE` in the Supabase SQL editor and not writing a migration.

The risk with a shared API and multiple clients is that a migration can break an older client before it has been updated. The API tests run in CI. If a migration changes a response shape, those tests should fail, which is the signal to update the spec and the clients before deploying.

What We Would Do Differently

We would generate TypeScript types from the OpenAPI spec earlier. The spec is generated from route definitions, so it cannot drift from the handlers, but contract tests for response shapes came later. Generated types used in the backend handlers would make that connection a compile-time guarantee rather than a test-time one.

We would also add structured API contract tests sooner - tests that verify the shape of every documented response, not just that the endpoint returns 200. These exist now, but adding them earlier would have caught several undocumented response shape changes before they reached the Flutter app.

If you are building a product that needs web and mobile clients and want to get the API architecture right from the start, talk to us. The decisions made in the first few weeks of backend design shape how much coordination work you will do for the life of the product. You can also read our comparison of React Native vs Flutter for ops-style apps if you are still deciding on a mobile technology.

Common questions

Should web and mobile apps share one backend API?

In ManOps, Zovintra's own tool, one versioned REST API at /api/v1 serves a Next.js web app and Flutter iOS and Android apps. The clients share the API contract rather than code, and an OpenAPI spec generated from route definitions made breaking changes visible early.

Why version a mobile API from the start?

Mobile users may not update immediately, so an app can run weeks behind the backend. The v1 prefix gives a stable contract to plan deprecations around, and the backend can check an X-Client version header and refuse clients below a configured minimum version.

Who can help design one API for web and mobile clients?

The decisions made in the first few weeks of backend design shape how much coordination work you do for the life of the product. Zovintra offers to talk through API architecture for products that need web and mobile clients.

Sources

TECHNICAL CONSULTATION

Building something similar?

Talk to our engineering team about your architecture, roadmap, and delivery timeline.

Request a Technical Review →

Related Reading

← Back to all posts