VIBE CRM was live on Vercel, serving real estate teams, handling deals and contacts and SMS sequences. The web app worked. But real estate agents don't sit at desks. They're in cars between showings, at open houses, walking properties with clients. They live on their phones. A web-only CRM was leaving value on the table every hour an agent was away from their laptop.
Here's how I added a React Native mobile app to an existing SaaS without rewriting the backend, duplicating the API, or losing my mind.
The Decision: Native vs React Native vs PWA
Three options. Each with trade-offs.
- PWA (Progressive Web App): Lowest effort. Wrap the existing web app in a service worker, add a manifest, call it mobile. The problem: push notifications are unreliable on iOS, offline support is limited to cache-only patterns, and camera access for business card scanning is janky through the browser. PWAs are fine for content consumption apps. For a CRM where agents need push alerts for new leads, they're not enough.
- Native (Swift + Kotlin): Best performance and platform integration. The problem: two codebases for two platforms, and I'm one developer. Maintaining a Swift iOS app, a Kotlin Android app, AND the Next.js web app would triple the surface area. Every new feature ships three times. Every bug gets fixed three times. Not sustainable for a solo dev.
- React Native (via Expo): One codebase for both platforms. Native push notifications that work. Camera access. Secure local storage. Offline-capable with SQLite. The performance gap with true native has narrowed enough that for a data-driven app like a CRM, users can't tell the difference. This is what I chose.
Sharing the API Layer
The biggest win of this approach: the Next.js API routes serve both the web app and the mobile app without any duplication. Every endpoint that the web app calls, the mobile app calls the same way. Same request format, same response format, same validation, same error handling.
The API was already designed as a RESTful JSON layer because Next.js API routes naturally produce that. I didn't need to build a separate mobile API. I didn't need GraphQL. I didn't need a BFF (backend for frontend) layer. The existing routes just worked. One backend, two clients.
The only adjustment: response pagination. The web app loads 50 contacts at a time because desktop screens have room for long lists. The mobile app loads 20 at a time with infinite scroll because smaller screens need smaller payloads. I added an optional limit parameter to the list endpoints. That was the extent of the API changes.
Auth Token Flow: Web vs Mobile
This is where web and mobile diverge in ways that aren't obvious until you're deep in implementation. Clerk handles auth for both platforms, but the token delivery mechanism is completely different:
- Web: Clerk sets an HTTP-only cookie on login. Every API request automatically includes the cookie. The server-side middleware reads the cookie, validates the session, and injects the user context. The developer never touches the token directly.
- Mobile: No cookies. Clerk issues a JWT on login. The mobile app stores it in Expo SecureStore (encrypted device storage). Every API request includes the token in an Authorization header. The server-side middleware detects the header, validates the JWT, and injects the same user context as the cookie path.
The middleware handles both paths transparently. It checks for a cookie first, then falls back to the Authorization header. The API route handlers don't know or care which client is calling — they just see a validated user context. Getting this dual-auth flow right took about two days of debugging edge cases: token refresh timing, expired session handling, and the race condition where a background sync request fires with a token that expired 30 seconds ago.
Offline-First with SQLite
Real estate agents lose signal constantly. Underground parking garages. Rural properties. Dead zones in suburban neighborhoods. A CRM that shows a spinner when there's no internet is useless for the exact moments agents need it most.
The offline strategy uses SQLite for local contact caching. On launch, the app syncs the agent's full contact list and recent deals to a local SQLite database. When the agent opens a contact or views their pipeline, the app reads from SQLite first and shows data immediately — zero loading state. If the device is online, a background fetch updates the local cache with any changes from the server.
When the agent makes changes offline — adds a note, updates a deal stage, logs a call — those changes write to SQLite with a "pending sync" flag. When connectivity returns, a background sync process pushes all pending changes to the server in order. Conflict resolution follows a last-write-wins strategy with timestamps, which is simple and good enough for a single-user-per-device CRM. If the same contact was edited on web and mobile simultaneously (rare), the most recent edit wins and the other is logged for manual review.
Push Notifications via Expo
This was the feature that justified the entire mobile app. When a new lead comes in, the agent's phone buzzes. When a deal moves to a new stage, the agent knows. When a contact replies to an SMS sequence, the notification appears immediately. These are the moments that matter in real estate — a 5-minute response time vs a 2-hour response time can be the difference between winning and losing a deal.
Expo's push notification service handles the complexity. The mobile app registers for push notifications on first launch, sends the device token to the server, and the server stores it alongside the user record. When a triggering event happens (new lead assigned, deal stage change, SMS reply received), the API route sends a push notification through Expo's service to all registered devices for that user.
Notification categories help agents triage without opening the app. New lead notifications are high priority (sound + vibration). Deal stage updates are standard priority (badge only). Sequence completions are low priority (silent, badge update). Agents can customize these tiers in their profile settings.
The UI Translation Problem
The web app's signature feature is the kanban pipeline — a horizontal board with draggable deal cards across customizable stages. It works beautifully on a 1440px screen. On a 390px phone screen, it's unusable. Horizontal scrolling through 6+ columns with tiny cards and drag handles is a terrible mobile experience.
Instead of shrinking the kanban to fit, I replaced it with a card-based deal view on mobile. Deals are displayed as a vertical list, grouped by stage, with swipe gestures to move a deal to the next or previous stage. Swipe right to advance a deal, swipe left to move it back. A long press opens the full deal detail view. The interaction feels native because it uses the gestures that mobile users already know from email and messaging apps.
Contact list views got a similar treatment. The web app shows a dense table with sortable columns. The mobile app shows a searchable list with contact cards showing name, last activity, and deal value. Tap to call, long press to text. The information density is lower but the action density is higher — the most common things an agent does (call a contact, text a contact, view a deal) are all one or two taps from the home screen.
Code Sharing Stats
After the mobile app was complete, I measured code sharing between the two clients:
- ~60% shared: All business logic, API client functions, data validation, type definitions, and utility functions are shared between web and mobile via a common package
- ~25% mobile-only: React Native components, navigation, offline sync, push notification handling, native gestures
- ~15% web-only: Next.js-specific components, server components, middleware, SEO metadata
That 60% sharing ratio means new features often only need to be built 1.4 times instead of twice. The business logic and types are written once. The UI is adapted per platform. It's not perfect code reuse, but it's dramatically better than maintaining two completely separate codebases.
Why Expo Over Bare React Native
For a solo dev, this isn't even close. Expo gives you:
- EAS Build: Cloud builds for iOS and Android without maintaining Xcode and Android Studio locally. I push code, EAS builds the binaries, I download the artifact. No 45-minute local builds eating up my CPU.
- Over-the-air updates: Bug fixes deploy instantly without going through app store review. Critical fix at 10pm? Push an OTA update and it's live in minutes.
- Managed native modules: Camera, push notifications, secure storage, SQLite — all available as Expo modules with consistent APIs. No manual native linking, no pod installs, no Gradle debugging.
The trade-off is less control over the native layer. For a CRM, that trade-off is irrelevant. I don't need custom native modules. I need reliable push notifications, a camera for business card scanning, and local storage. Expo delivers all of that without requiring me to become an iOS and Android specialist.
The Result
VIBE CRM now runs on web, iOS, and Android from a shared codebase with a shared backend. Agents get push notifications for new leads within seconds. Contact data is available offline. Deal management works via swipe gestures that feel native. And the entire mobile app was added to the existing SaaS without rewriting a single API route.
If you have a web SaaS and your users live on mobile, React Native via Expo is the highest-leverage path to get there as a solo developer. The portfolio has the full project details.