Deep dive · KESHO
Shipping KESHO with Capacitor
Capacitor did its job. Both apps built and both reached review. The mistake was what I asked it to carry, and when.
Setup
A native shell around a live website
KESHO was a Next.js app deployed on Vercel. For the stores, I wrapped it in Capacitor. The iOS and Android apps were native shells whose WebView loaded the production site, with plugins for push notifications, sharing, haptics, the status bar, the splash screen, network state, the system browser, and native Google sign-in.
The upside was real. Most fixes shipped with a normal web deploy and reached the apps without a new binary or a new review. Android reached Google Play closed testing. iOS was submitted to the App Store, and the rejection that came back was about World Cup branding, not about the app being built with Capacitor.
So this isn’t a story about a tool failing. It’s about cost. Almost every hard problem lived in the seams between the web app and the platforms around it.
Fit
When Capacitor is a good choice
- You already have a solid web app, and the mobile app is mostly the same screens.
- The product is content, forms, and lists rather than gestures, animation, or heavy device work.
- You need store presence, push, or a home-screen icon more than you need native feel.
- Your team is web-first and can’t sustain a separate native codebase.
And it becomes the wrong abstraction when:
- The hardest parts of the product are platform behavior: sign-in, deep links, background work, process lifecycle.
- You’re using it to postpone a decision between web and native rather than to make one.
- The app is little more than a wrapped website. Store reviewers look hard at those.
KESHO’s hardest parts were the first item on that second list.
Auth
Sign-in is where WebViews hurt
Sign-in took more iterations than any other part of the native work. The path, in order:
Google refuses embedded WebViews.
Signing in with Google inside the app’s WebView was rejected outright. Google blocks OAuth from embedded browsers, so the flow had to leave the WebView.
Handing off to the browser breaks state.
Sending the user to the system browser and back via a deep link works, but PKCE needs a verifier generated before the trip to still exist after it. On Android, the OS sometimes killed the app while the user was in the browser. The verifier went with it, and the returning sign-in failed.
Coming back is its own problem.
The deep link fired, but the session didn’t persist, because the WebView and the system browser don’t share cookies or storage.
Native first.
The stable Android answer was native Google sign-in that returns an ID token, exchanged directly with the auth provider. No browser round-trip, no state to lose. Apple sign-in on iOS still used the browser fallback.
In the middle of this I also moved auth from Clerk to Supabase Auth. An auth abstraction I had built earlier meant app code didn’t change. The native flows did.
If you ship Capacitor with sign-in, design for native auth from day one. Use the platform SDKs, exchange tokens server-side, keep any state that must survive a handoff in durable storage, and test what happens when the OS kills the app halfway through.
Remote vs bundled
Loading the live site is a trade, not a shortcut
Pointing the shell at the production URL gave KESHO instant fixes. It also meant:
- When the site broke, the apps broke with it.
- Web code and native code shipped on different schedules, so a web deploy could call a plugin an installed app didn’t have yet.
- Offline meant a fallback page, not an app.
- To a reviewer, a remote shell can look like a website in a box.
Bundling the web build into the binary fixes most of that, at the price of a release for every change, or a live-update service on top. Either can be right. The mistake is drifting into one without deciding.
Deep links
One door into the app
Deep links had three failure modes. Verified links on Android only work on https hosts with a published association file, and I first marked a custom URI scheme as verified, which does nothing. Links behave differently when the app is cold-started versus already open. And the link handler could fire before the router was ready, so navigation raced app startup.
What I would do now: a single entry point that receives every incoming URL, queues it until the app has finished starting, then routes it. Auth callbacks, shared Call links, and notifications all come through the same door.
Testing
Real devices, every native change
Most native bugs in KESHO only appeared on real devices: memory pressure on one Android phone, and an iOS build failing in CI on package resolution.
Before any native release I would now run a short scripted check on a real iPhone and Android phone:
- Fresh install, sign up, sign in, sign out, sign in again
- Kill the app mid sign-in, return, confirm recovery
- Open a deep link with the app closed, then with it open
- Receive and open a push notification
- Update from the previous build without losing the session
Then automate the sign-in path first, because that is where most of the time went.
Today
What I would choose now
For KESHO at the evidence level it had, I would have stayed on the mobile web, installable as a web app, until the beta showed that people came back. The store work began before there was meaningful beta evidence, and the product didn’t need it to learn what it needed to learn.
Once native was justified, I would still consider Capacitor for a web-first team. I would bundle the web build, use native auth SDKs from the start, keep the plugin list short, and budget release time as real work. If the product’s core were native interaction rather than screens of content, I would pick a native or React Native stack instead.
Capacitor didn’t fail. I asked it to carry a mobile launch before the product had earned one.