Building an iOS App as an Afterthought (and Why It Worked)
We shipped a mobile app in a day by making one good architectural decision weeks earlier: sharing everything.
5 min read
The Decision
On March 25th, three and a half weeks into building EdgeGoat, we shipped an iOS app:
March 25 ios first pass, fixes to builder
One commit. One day. A fully functional mobile app on iOS that does everything the web app does — live parlay tracking, the smart builder, odds comparison, strategy optimization, all of it.
This wasn't because we're fast. It was because we're lazy in the right way.
The Architecture That Made It Possible
When we started EdgeGoat, we made a decision that seemed like overkill at the time: a monorepo with a shared UI package.
packages/
shared/ — types, constants, utils (non-React)
ui/ — React components, hooks, contexts, services
apps/
web-app/ — BrowserRouter, imports from @edgegoat/ui
mobile/ — Capacitor + MemoryRouter, imports from @edgegoat/ui
server/ — Fastify backend
The packages/ui package contains everything visual — components, hooks, context providers, pages. The web app and mobile app are thin shells that provide routing and platform-specific configuration, then render the same components from @edgegoat/ui.
This means when we built the mobile app, we didn't rewrite a single component. Not one. The parlay tracking page, the smart builder, the odds comparison view — they all came from @edgegoat/ui unchanged.
Why Capacitor
We picked Capacitor (by the Ionic team) over React Native for one reason: it runs the actual web app in a native WebView. There's no bridge, no translation layer, no "similar but different" component set. It's your React app, running in a browser, wrapped in a native shell.
The tradeoffs are real:
- Performance: A WebView is slower than native rendering. For a data-heavy app with lots of lists and real-time updates, this matters. But EdgeGoat is primarily text, numbers, and simple charts — not the kind of UI where native performance is critical.
- Native features: Capacitor gives you access to native APIs (camera, push notifications, haptics) through plugins. We don't need most of them. We need WebSockets (works in WebView), HTTP requests (works in WebView), and local storage (IndexedDB works in WebView). Done.
- App Store approval: Apple is famously picky about "web app wrappers." But Capacitor apps pass review if they provide a native-quality experience and use native navigation patterns. Our bottom tab bar and navigation transitions look native enough.
The alternative — React Native — would have meant rewriting every component. Different component primitives (View instead of div, Text instead of span), different styling (StyleSheet instead of CSS/Tailwind), different navigation (React Navigation instead of React Router). For a team of one, that's a non-starter.
What Had to Change
Not much, but some things:
Routing
The web app uses BrowserRouter (URL-based routing, /games/123). The mobile app uses MemoryRouter (in-memory routing, no URL bar). This was a one-line change in the app shell.
Environment variables
The mobile app needs to know the API base URL, which is different from the web app (the web app uses relative paths, the mobile app needs the full https://api.edgegoat.com URL). This lives in the mobile app's .env file.
Bundle ID and app metadata
com.edgegoat.app, app icons, splash screen, iOS-specific metadata. Standard Capacitor configuration, nothing surprising.
Touch targets
Some of our web UI had click targets that were fine for mouse but too small for fingers. We went through and bumped the tap targets on key interactive elements — buttons, list items, toggleable sections. This was the most time-consuming part, and it was still only a few hours.
Safe area handling
iOS has the notch, the Dynamic Island, the home indicator bar. Our layout component needed safe area insets so content doesn't render behind system UI. Capacitor provides these through CSS environment variables (env(safe-area-inset-top)). A few CSS lines in the layout wrapper.
What Didn't Have to Change
Everything else:
- All page components (HomePage, GameDetailPage, TrackingPage, SmartBuilderPage, BacktestPage)
- All context providers (ParlayContext, ToastContext, UserAuthContext)
- All hooks (useGameDetail, useScoreboard, useLiveTracking, useParlay)
- All API calls and WebSocket connections
- All data transformations and business logic
- IndexedDB caching (works identically in mobile WebView)
- Tailwind CSS styling (one stylesheet, both platforms)
This is the payoff of the shared UI package architecture. ~95% code sharing between web and mobile with no abstraction layer, no "platform: 'ios' ? nativeComponent : webComponent" branching, no separate codebase.
The Apple Certificate Saga
Building the app was a day. Getting it on a device was... longer.
April 1 certificate permissions
April 2 fixes: apple and stuff
Apple's code signing and provisioning profile system is its own circle of developer hell. You need:
- An Apple Developer account ($99/year)
- A development certificate
- An app ID
- A provisioning profile that ties the certificate to the app ID to specific devices
- Xcode configured correctly (which it never is on the first try)
"fixes: apple and stuff" is the commit message of someone who has just spent hours fighting Xcode and no longer has words.
Live Tracking on Mobile
The killer feature on mobile is live parlay tracking. You're at a bar watching the game, and your phone shows all your active parlay legs with real-time updates. Progress bars filling up. Probabilities shifting. The on-court indicator showing your guy just checked back in.
This works through the same WebSocket connection as the web app. The Fastify server pushes live updates to all connected clients — web or mobile, doesn't matter. The useLiveTracking hook manages the subscription, and the components re-render with fresh data.
On mobile, we also integrated basic haptic feedback — a subtle vibration when a leg settles (either hit or miss). It's a small touch, but when you're watching multiple games and a leg quietly hits in the background, the haptic is a satisfying confirmation.
Would We Do It Differently?
If we were building a mobile-first app with complex animations, camera features, or heavy local computation, Capacitor wouldn't be the right choice. React Native or native Swift/Kotlin would be better.
But for a data-driven app that's primarily about displaying numbers, tables, and charts with real-time updates? The web stack is perfect. And the code sharing benefits are massive.
The decision to build @edgegoat/ui as a shared package wasn't made with mobile in mind. We did it for code organization — separating UI from routing and platform concerns. The mobile app was a free bonus that fell out of a good architectural choice.
Sometimes the best features are the ones you accidentally make possible.
Our iOS app shares 95% of its code with the web app. We shipped it in a day. The architecture made it easy — the Apple certificates made it hard.