Most "React Native vs. Flutter" posts compare benchmarks. This one doesn't.
In 2026 I shipped two React Native apps for real businesses. People use them to run their working day, not to try out a demo. Here is what actually mattered, including two mistakes that cost us time.
TL;DR
- Treat the app as a second door into your backend, not a second system.
- Start with Expo. Go bare only when Expo starts slowing you down.
- Build roles and permissions in week one.
- "Done" means tested on real devices, with real data, over time.
- Store requirements are engineering work. Plan them early.
Table of contents
- The two apps
- Lesson 1: the app is a second door, not a second system
- Lesson 2: Expo or bare? We used both
- Lesson 3: permissions belong in week one
- Lesson 4: the bug 204 tests didn't catch
- Lesson 5: the store is part of the build
- So, React Native or Flutter?
The two apps
myNextDays is an app for holiday-rental hosts and small hotels. It puts bookings, the occupancy calendar, guest messages and cleaning tasks on the phone. It is live on Google Play.
Windhunde is a monitoring app for a company that operates and services wind turbines. It shows live values and alarms, and lets authorised staff control turbines remotely. It runs on iPhone, iPad and Android.
Two very different industries. Same framework. Two different setups.
| myNextDays | Windhunde | |
|---|---|---|
| Industry | Holiday rentals | Wind energy |
| Setup | Bare React Native | Expo + Expo Router |
| Runtime | New Architecture, Hermes | New Architecture, Hermes |
| Backend | FastAPI + self-hosted Supabase | FastAPI + PostgreSQL/TimescaleDB |
| Hosting | Hetzner, EU | Hetzner, Germany |
| Size | 21 screens, 204 tests | 10 screens, 200 commits |
Lesson 1: the app is a second door, not a second system
The most important decision in both projects had nothing to do with React Native.
Neither app has business logic of its own. Both call the same API as the web dashboard and work on the same data. Prices, permissions and status changes are always decided by the server.
It sounds boring. But it prevents the most common problem with business apps: web and app slowly drifting apart until they show different numbers.
With one source of truth, they can't contradict each other. And a new feature on the web can be brought to the app without re-implementing rules.
What this means for you: before you pick a framework, decide where the rules live. If the answer is "in the app", stop and rethink.
Lesson 2: Expo or bare? We used both
myNextDays is a bare React Native project. Windhunde uses Expo with Expo Router. Both work. They just fit different situations.
| You need… | Our pick | Why |
|---|---|---|
| iOS, iPadOS and Android from day one | Expo | Secure storage and biometric login come ready-made |
| File-based routing that mirrors a Next.js web app | Expo Router | Same route structure on web and mobile |
| Full control over native projects and push setup | Bare | Deep integration with existing workflows and FCM |
Expo Router turned out to be a quiet win for Windhunde. Its file-based routes mirror the Next.js app directory of the web dashboard. That keeps both codebases easy to navigate. It also gives every screen its own deep link, which is exactly what you need when a push notification should open one specific alarm.
Our rule of thumb: start with Expo unless something speaks against it. Switch to bare once you spend more time working around Expo than building with it.
Lesson 3: permissions belong in week one
myNextDays has six roles, from admin down to cleaning staff.
Exactly one place in the code decides what someone sees. It uses the permissions the server returns, not the role name. If an unknown role shows up, the app signs the user out instead of falling back to the manager dashboard.
Here's the idea in simplified form:
// One place decides what a user sees, driven by server permissions.
type Me = { permissions: string[] };
const TABS = [
{ key: "dashboard", needs: "dashboard.view" },
{ key: "calendar", needs: "calendar.view" },
{ key: "bookings", needs: "bookings.view" },
{ key: "tasks", needs: "tasks.view" },
] as const;
export function tabsFor(me: Me | null) {
if (!me) return null; // not signed in
const tabs = TABS.filter((t) => me.permissions.includes(t.needs));
// Unknown or empty permission set: sign out instead of guessing.
return tabs.length > 0 ? tabs : "sign-out";
}
Amounts and full guest names are masked by the backend. The app never fills them in.
Had we added permissions at the end, we would have touched every screen twice. It's not a React Native problem. But it's where app budgets regularly tip over.
Lesson 4: the bug 204 tests didn't catch
myNextDays has 22 test suites with 204 tests. They cover token storage and refresh, inbox access rules, time zones, status chains and account deletion.
Beta testers still found a bug none of them showed.
The app crashed in the morning. The reason: the auth token's expiry time only lived in memory. When the OS killed the app overnight, that value was gone. On fresh test devices this never happened, because nobody left them alone for a night.
The fix was small. Simplified:
import * as SecureStore from "expo-secure-store";
// Before: expiresAt lived only in memory and was lost
// when the OS killed the app overnight.
// After: persist it next to the token and read it back on cold start.
export async function saveSession(token: string, expiresAt: number) {
await SecureStore.setItemAsync("token", token);
await SecureStore.setItemAsync("expiresAt", String(expiresAt));
}
export async function needsRefresh(): Promise<boolean> {
const raw = await SecureStore.getItemAsync("expiresAt");
return !raw || Date.now() > Number(raw) - 60_000; // refresh 1 min early
}
(Expo's secure store keeps the snippet short. In the bare project the same idea runs on the platform keychain and keystore.)
Windhunde took the same lesson further. There, a task only counts as done after a verification loop on real devices with real data:
- App and web show the same live value in the same second, proven by two screenshots side by side.
- After returning from the background, the app fetches a fresh snapshot.
- After toggling airplane mode, it reconnects without a restart.
- It runs for 30 minutes without a single error in the server log or the app console.
Those checks produced features nobody had specified, like a visible connection-status bar.
Lesson 5: the store is part of the build
In-app account deletion, privacy details, the privacy manifest, Google's Data safety form. None of this is paperwork for launch day.
For myNextDays we checked the data-safety answers line by line against the final build. We found three things missing: photos, device IDs and crash data.
Then the screenshots. Ours were taken on an iPhone with an aspect ratio of 2.17:1. Google Play only accepts up to 2:1. We had to retake all of them.
Two things paid off in the end:
- A read-only demo tenant with eight fictional properties. Store reviewers and sales demos use it, and no real guest data ever shows up in a screenshot. One lesson from production: users read the write-block as a bug. So now a permanent banner says "Demo: view yes, save no".
- Data minimalism. The app has no analytics, ad or tracking SDK. On Android it requests only internet, notifications and camera. For hosts who handle guest data, that's a real sales argument. It only exists if you treat it as a requirement from day one.
So, React Native or Flutter?
Flutter is an excellent framework, especially when the design must look pixel-identical everywhere.
For us one thing tipped the scale: the existing web. Windhunde's dashboard is written in Next.js and React. With React Native we share the display and calculation logic as a TypeScript package between web and app. That saved more time than any benchmark lead could have.
So the honest answer is usually: it depends less on the framework and more on what you already have.
What's the one thing you wish you had known before shipping your first business app? I'd love to read your stories in the comments.
I'm Andy Staudinger, a freelance developer in Germany building websites and mobile apps for iOS and Android. The full case studies (in German): myNextDays · Windhunde. Next in this series: how the Windhunde app streams live data without polling.



Top comments (1)
Same split here. The web app and the React Native app both call one Laravel API, and streaks and XP only ever get counted on the server. It started as a PWA, and that's what made swapping the whole client for React Native possible without rewriting the rules.