The pattern every recipe follows
- Base path — assets must not be requested from
/. Relative (./) where the tool supports it;/web/where it does not. - Routing — hash mode, for the reasons in SPA routing.
- Build-time configuration — anything read from environment variables is frozen into the bundle when you build.
- Service workers — unnecessary in an app whose files are already local; guard the registration.
- Output — zip the contents of the build folder, not the project.
Quick reference
| Tool | Base setting | Zip this folder |
|---|---|---|
| Vite (React, Vue, Svelte, Solid, Preact) | base: './' | dist/ |
| Create React App | "homepage": "." | build/ |
| Vue CLI | publicPath: './' | dist/ |
| Angular 17+ | --base-href ./ | dist/<app>/browser/ |
| SvelteKit | adapter-static, paths.relative: true | build/ |
| Next.js | output: 'export', basePath: '/web' | out/ |
| Nuxt 3 | ssr: false, app.baseURL: '/web/' | .output/public/ |
| Parcel | --public-url ./ | dist/ |
| Flutter web | --base-href /web/ | build/web/ |
| Expo (web) | experiments.baseUrl: "/web" | dist/ |
Environment variables are baked in
import.meta.env.VITE_API_URL, process.env.REACT_APP_API_URL and NEXT_PUBLIC_* are replaced by their values at build time. Whatever was in .env when you ran the build is what ships. Two consequences:
- Build with production values — an app pointing at
http://localhost:3000cannot reach anything on a phone. - Every such value is readable by anyone who unzips the APK. Public client keys (Firebase config, Supabase anon key, Stripe publishable key) are designed for that. Secret keys never belong in a front-end build, app or not.
PWA plugins and service workers
vite-plugin-pwa, Workbox, Angular's service worker and Create React App's serviceWorkerRegistration all try to register a service worker. In the app, that registration fails — service-worker requests do not go through the app's file server — and the app does not need one, because its files are already on the device. Either leave the plugin out of the APK build or guard the call:
if ('serviceWorker' in navigator && location.hostname !== 'appassets.androidplatform.net') {
navigator.serviceWorker.register('./sw.js')
}
The same check is a reliable way to detect "running inside the app" anywhere in your code.
Framework-specific notes
Vite
Modern Vite output — <script type="module" crossorigin>, code-split chunks, dynamic import() — runs as-is, because the app serves a real https origin. You do not need @vitejs/plugin-legacy or single-file plugins for the app's sake; add legacy output only if you target very old WebViews.
Angular
Zip browser/, not dist/<app>/ — the latter also holds server/ and 3rdpartylicenses.txt and has no index.html at its top. If you use Angular's service worker (@angular/service-worker), disable it in the APK build's configuration.
Next.js
Only the static export applies: no API routes, middleware, server actions, ISR or next/image optimisation. Next.js cannot emit relative asset URLs, so basePath: '/web' makes its absolute URLs land in the right folder. Client-side navigation with <Link> works; avoid full-page reloads to deep URLs.
Nuxt
Use ssr: false with nuxi generate for a single index.html, and router.options.hashMode: true. Server routes in server/ do not exist in the app — call a deployed API instead.
Flutter web
Build with --no-web-resources-cdn so the CanvasKit renderer is bundled; otherwise it is downloaded from Google at startup and the app is blank offline. It adds several megabytes — check the size limit. If you have Flutter source, Flutter's own Android build produces a native app that is usually the better choice.
Verifying any build
- Open the output
index.htmlin an editor: nosrc="/orhref="/except/web/ones. - Serve the output folder from a sub-path locally —
npx servefrom the parent folder and open/dist/— to simulate not being at the root. - Upload to the builder and use the preview, which serves files the same way the app does.