Quick answer: run the production build with a relative base path (
base: './'in Vite,"homepage": "."in Create React App), make sure the output is a classic script rather than an ES module (Vite:@vitejs/plugin-legacyor an IIFE output), switch the router to hash mode, then zip the contents ofdist/(orbuild/) soindex.htmlis at the root and upload it to the ZIP to APK builder.
Why the default build fails in an app
Bundlers assume a web server at a known root. Three assumptions leak into the output: asset URLs start with /, the entry script is loaded as type="module", and the router expects the server to return index.html for any path. Inside an APK there is no server and the page has no origin, so each assumption breaks — the blank-screen guide explains the symptoms. Each has a one-line config fix.
Vite (React, Vue, Svelte, Solid, vanilla)
// vite.config.js
import { defineConfig } from 'vite';
import legacy from '@vitejs/plugin-legacy'; // npm i -D @vitejs/plugin-legacy
export default defineConfig({
base: './', // relative asset paths
plugins: [
legacy({ renderLegacyChunks: true, modernPolyfills: false }),
],
build: { outDir: 'dist' },
});
@vitejs/plugin-legacy emits a classic <script nomodule> fallback alongside the module build; on a file:// page the module is blocked and the fallback runs. If you would rather not ship both, force a single classic bundle instead:
build: {
rollupOptions: { output: { format: 'iife', inlineDynamicImports: true } },
modulePreload: false,
}
…and change the <script type="module"> in the generated index.html to a plain <script> (a small post-build step, or use the vite-plugin-singlefile package which inlines everything into one HTML file — ideal for the single-file route).
Create React App
// package.json "homepage": "."
CRA already outputs classic scripts. Zip the contents of build/.
Vue CLI
// vue.config.js
module.exports = { publicPath: './' };
Angular
ng build --base-href ./ --configuration production
Angular 17+ emits ES modules by default. Set "outputMode"/"target" so the browser build includes a classic fallback, or serve through the legacy builder; and use provideRouter(routes, withHashLocation()).
Next.js (static export only)
// next.config.js
module.exports = { output: 'export', assetPrefix: './', trailingSlash: true, images: { unoptimized: true } };
Zip the out/ folder. Anything that needs a server — API routes, server components, ISR, middleware — is unavailable.
Routing: use hash mode
| Router | Change |
|---|---|
| React Router | BrowserRouter → HashRouter |
| Vue Router | createWebHistory() → createWebHashHistory() |
| Angular | withHashLocation() / useHash: true |
| SvelteKit | Static adapter with router: { type: 'hash' } (SvelteKit 2.x) or a client-only fallback page |
URLs become index.html#/about. Nothing else in your app changes.
Data and APIs
Requests to a real https:// API work when the phone is online, with CORS evaluated against Origin: null — if your API whitelists origins, allow null or use a wildcard. Local JSON in the bundle must not be fetched (see what works offline); import it into the bundle at build time instead — import data from './data.json' — so it becomes part of the JavaScript.
Then zip and convert
- Build:
npm run build. - Open
dist/index.htmlfrom disk in Chrome. If it renders and the console is clean, you are done with config. - Zip the contents of
dist/— the Folder to ZIP tool does it correctly and skips source maps. - Upload to the builder, set name, icon, package name, version, build.
Checklist
- Relative base path
- Classic script output (or legacy fallback)
- Hash routing
- No fetch of bundled files; JSON imported at build time
- Zip dist/, not the project
- Renders from file:// in desktop Chrome