Skip to content
rsdlt.dev
Go back

One React Codebase for Web, iOS and Android: Vite, TanStack and Capacitor

Brewers plan recipes at a desk and log readings standing next to a fermentation tank with a phone in one hand. Foambyte, the production-management platform my brother Diego and I build for craft brewers, has to work well in both places. Diego is a brewmaster and leads the product; I’m the only developer, so I need one codebase for the web, iOS and Android, not three.

This post explains the frontend architecture I chose and why I think it’s the right trade-off for a product like this.

The decision: a SPA packaged with Capacitor

There were three realistic options:

OptionWhat you getWhat it costs
Next.js (SSR)Server rendering, great SEOA server runtime, and SSR gives nothing to pages behind a login
React NativeNative UI widgetsA second component system; web components and Tailwind styles don’t carry over
Vite SPA + CapacitorOne build output for web, iOS and AndroidNative feel depends on careful use of native plugins

I went with the third option for three reasons:

  1. Everything useful sits behind a login. Recipes, batches and tanks are private data, so server rendering would add infrastructure without any SEO return. The SEO work lives on a separate static Astro marketing site, which is the right tool for that job.
  2. Capacitor needs static, client-rendered files. The same dist/ folder that Cloudflare Pages serves is the folder Capacitor wraps into the Android and iOS apps. A SPA gives me that for free.
  3. The app is forms, tables and charts. That is exactly what the web platform is good at. Nothing in Foambyte needs 120 fps native list virtualization or a custom camera pipeline.

The stack

The common thread is type safety end to end. TanStack Router types route params and search params, TanStack Query types every cache entry, and the API types come straight from the server (more on that in a later post).

Native where it matters, web everywhere else

Capacitor’s value isn’t the web view. It’s the small set of native plugins that make a web app feel like it belongs on the phone:

The API client is deliberately boring. Every platform uses plain fetch; the only platform-specific decision is the base URL:

// simplified
const isNative = Capacitor.isNativePlatform();

this.baseUrl =
  mode === "development" && !isNative
    ? `${import.meta.env.VITE_API_URL_DEV}/api`
    : `${import.meta.env.VITE_API_URL_PROD}/api`;

One code path means one set of bugs. The client sends credentials, retries idempotent GETs with exponential backoff on 5xx or network errors only, and throws a typed ApiError that the UI can reason about.

Server state: the query-key / query-options / mutation-options triplet

The pattern that has paid off the most is a strict file convention for every resource. Each one gets three files:

Query keys are data, so they get their own module:

export const QK_GET_ONE_BATCH = "get_one_batch" as const;

export function qkGetOneBatch(batchId: string | undefined) {
  return [QK_GET_ONE_BATCH, { batchId }] as const;
}

Query options are reusable recipes for fetching:

export function qoGetOneBatch(batchId: string | undefined) {
  return queryOptions<BatchResponse>({
    queryKey: qkGetOneBatch(batchId),
    enabled: !!batchId,
    queryFn: () => fapi.request("GET", batchUrls.one(batchId!)),
    staleTime: 60_000,
  });
}

The same factory works in a component (useQuery), in a route guard, or in an atom (ensureQueryData), and there’s no chance of two call sites spelling a key differently.

Mutation options own their side effects. A batch update applies optimistically, rolls back on error, and invalidates every list that might show stale data:

onMutate: async (variables) => {
  await queryClient.cancelQueries({ queryKey: qkGetOneBatch(batchId) });
  const previousData = queryClient.getQueryData<BatchResponse>(qkGetOneBatch(batchId));
  // ...apply the patch to the cached batch...
  return { previousData };
},
onSuccess: () => {
  void queryClient.invalidateQueries({ queryKey: [QK_LIST_BATCHES] });
  void queryClient.invalidateQueries({ queryKey: [QK_LIST_BATCH_SUMMARIES] });
},
onError: (_e, _v, ctx) => rollbackBatch(batchId, ctx?.previousData),
Tip

Put cache invalidation in the mutation, not in the component that calls it. A component knows what the user clicked; the mutation knows what data changed. When terminating a batch frees a tank, only the mutation should know that the tank cache has to be refreshed.

Jotai for the recipe editor

TanStack Query is the source of truth for server data. But the recipe editor has dozens of fields, live calculations and undo/redo, and re-rendering a whole recipe form on every keystroke is wasteful.

So the editor reads the recipe through Query and exposes it as per-field Jotai atoms, using jotai-tanstack-query to share the same QueryClient:

export const recipeQueryAtom = atomWithQuery((get) => ({
  ...qoGetOneRecipe(get(recipeIdAtom)),
}));
export const recipeROAtom = atom((get) => get(recipeQueryAtom).data?.recipe);
export const recipeNameAtom = recipeAtomWithSync((r) => r.name);

Each field atom knows how to sync itself back to the server. The split is clear: Query owns what the server says; Jotai owns what the user is editing right now.

Canonical units at the edges

Brewers mix units freely: Celsius or Fahrenheit, liters or gallons, specific gravity or Plato. Foambyte’s rule is simple: everything stored and sent to the API is metric. The UI converts to the user’s preferred units for display and back to metric before every mutation, using converters from a shared @repo/brewing-formulas package that the marketing site’s public calculators also use.

Unit bugs are the brewing equivalent of off-by-one errors. Keeping conversion at the edges means the API, the database and every calculation only ever see one unit system.

Takeaways

In the next post I’ll cover the other half of this setup: how the frontend and the Hono API share a single, type-checked contract.


I’m Rodrigo Santiago, a product leader who builds. I co-founded Foambyte with my brother Diego, a brewmaster who leads the product as CPO, and I designed and built the platform: architecture, frontend, backend, mobile and infrastructure. If you need someone who can own both the product and the code, let’s talk on LinkedIn.


Share this post:

Previous Post
End-to-End Type Safety Without tRPC: A Shared Hono + Zod OpenAPI Contract
Next Post
Building a Recipe Calculation Engine in TypeScript: IBU, SRM, Fermentability and FG