Skip to main content

What we learned shipping a Next.js web app as a native Android app with Capacitor

Updated 6 min readMarkdown

We've been running a Next.js App Router web app in production for about a year, also shipped as an iOS app via Capacitor. Last month we added Android. Along the way we hit a few problems worth documenting — particularly one class of prerender failure that bit us twice before we automated a check for it.

The app itself is an AI admin tool for UK tradespeople. The domain is not especially important for what follows.


The Capacitor model: loading from a URL vs bundling

Capacitor gives you two options. You can bundle your built web app into the native binary, or you can point it at a live URL and have the WebView load from there.

We went with the live URL approach. The practical effect is that every web deploy propagates to both native apps instantly — no native build, no store submission, no review wait. For a team shipping frequently, this is significant.

The cost is a hard network dependency. The app is not useful offline. For a productivity tool used by people working in basements and rural sites, that's a real limitation we're aware of and haven't solved yet.

Whether the tradeoff is right depends entirely on your shipping cadence and your users' connectivity. It's worth making the decision deliberately rather than landing on it by default.


You cannot detect the native app by user agent

The iOS WebView overrides its user agent to look like Safari. This is worth knowing because it means any if (isMobile) or if (isApp) logic based on UA strings will silently misbehave.

Capacitor injects a JavaScript global into the page regardless of what the user agent says. Check that instead. It's available on both iOS and Android and is the reliable signal.

The practical places this matters: file download handling (WebView behaviour differs from browser), payment flow gating (App Store guidelines require different flows inside native apps), and analytics (you probably don't want native app sessions mixed into your web funnel).


A prerender failure that compiles cleanly

This is the one worth documenting in detail because it's not obvious and we got burned by it twice.

The failure mode: a page references a variable in JSX that is never declared or imported. This compiles without errors under TypeScript. ESLint does not catch it. The only moment it fails is during Next.js prerender, when the server tries to execute the component.

When this happens in a Vercel deployment, the build fails. What Vercel does on a failed build is keep serving the previous successful deploy. So from the outside, the site looks fine. The only signal is a build failure notification, which is easy to miss at the wrong time of day.

The first time this happened to us, five days passed before anyone noticed. The second time, three hours.

We now run a static check on every push that scans every .tsx file under src/app for this pattern: a known identifier used inside JSX with no corresponding declaration or import in the same file. It runs in a few seconds and exits non-zero if anything is found. It's not a substitute for a full build check, but it catches this specific failure class before the push rather than after.

The TypeScript compiler API makes this straightforward — ts.createSourceFile parses a file without needing a full project setup, and parseDiagnostics on the resulting source file catches syntax errors that would otherwise only surface at runtime.


Store policy differences that actually affect your code

App Store guideline 3.1.1 means any subscription purchase that happens inside an iOS app must go through In-App Purchase. The rule extends further than the obvious case: you can't link to a web checkout, describe pricing, or reference a free trial that isn't delivered through IAP, within the app context.

This means the iOS and Android apps need to present different UI for anything involving subscriptions or pricing. We handle this with a component that checks the runtime platform and renders different content accordingly. The same page serves both — the conditional is at the component level, not the route level.

Google Play does not have an equivalent restriction on external payment links. This made the Android build structurally simpler in one meaningful way.

The other platform difference worth knowing about: deep link verification. Both iOS and Android use a file hosted at /.well-known/ on your domain to verify that your app is allowed to handle links for that domain. The file formats are different. On Android, the verification file needs a SHA-256 fingerprint of your app signing certificate. If you're using Play App Signing — which Google now effectively requires for new apps — the relevant fingerprint is in Play Console, not in your local keystore. They are different values. Using the wrong one will cause your app links to silently fall back to the browser with no error.


Two deployment targets from one repo

We run two Vercel projects from the same GitHub repository — production and staging. Both auto-deploy on push to their respective branches. Environment variables are managed separately per project.

The failure mode we hit: added a new environment variable to the production project, forgot to add it to staging. The staging environment silently received undefined for that variable for several days. No error was thrown at startup — the variable was only read at request time, in a code path that wasn't hit by our basic smoke tests.

The mitigation is procedural: treat "add to both projects" as a checklist step whenever an environment variable changes. There's no technical guard for this in Vercel's current tooling unless you manage env vars through something like Doppler and sync from there.


What we haven't solved

Offline support is the obvious gap in the current architecture. Quotes and invoices are small, structured data — the case for caching them locally is clear. We're looking at service workers and local-first approaches for a future version.

The other open question is build time. npm ci on each CI run takes longer than it should. We're caching node_modules via actions/cache keyed on the lockfile hash, but cold cache runs on GitHub's Ubuntu runners are still slower than we'd like for a check that's meant to give fast feedback.

We built Sleepless Tradesman for UK tradespeople who spend a third of their working hours on admin: writing quotes, chasing invoices, figuring out what they owe in tax. It’s one of the top-rated tradesman apps in the UK — and a year after launching on iOS, we just shipped on Android.


If you've shipped something similar or solved the offline problem in a Capacitor app without giving up the live-URL deployment model, interested to hear how in the comments.

The Play App Signing fingerprint point is a good one, that mismatch is easy to miss because the app links just fall back to the browser silently. On the offline gap with the live-URL model, a service worker that caches the app shell and serves stale API reads for quotes and invoices would keep it usable without bundling. It is also worth checking whether the server errorPath option in the Capacitor config can show a local offline page when the URL fails to load.