Quick answer: a website converts cleanly when nothing in it assumes a web server. That means relative paths, lowercase filenames without spaces, fonts and libraries stored locally, a bundler base path of
./, hash-based routing for single-page apps, and a viewport meta tag. Test by openingindex.htmldirectly from your file manager — what breaks there breaks in the app.
A layout that packs well
app/ ├── index.html ← entry page at the root ├── pages/about.html ├── css/site.css ├── js/site.js ├── img/hero.webp ├── fonts/body.woff2 └── data/items.json
Nothing special about it. What matters is that every reference from one file to another can be written relative to the file making it, and the tree is complete on its own.
Five rules
1. Relative paths
From index.html: css/site.css. From pages/about.html: ../css/site.css. Never /css/site.css — there is no root inside an app.
2. Lowercase filenames
Android's filesystem distinguishes Hero.JPG from hero.jpg; your desktop probably does not. Lowercase everything and the mismatch cannot occur.
3. No spaces or accents in names
team photo.png becomes team%20photo.png in a URL and every layer that re-encodes it is a chance to break it. Use hyphens.
4. Keep assets local
Google Fonts, CDN scripts and remote stylesheets vanish offline. Download them into the project. Local files also load faster — no DNS, no TLS.
5. Include a viewport tag
<meta name="viewport" content="width=device-width, initial-scale=1">. Without it the WebView lays the page out at desktop width and shrinks it.
Bundler settings that bite
Vite, webpack and Parcel default to absolute asset URLs like /assets/index-4f2a.js — right for a server, wrong for a bundle. Make the base relative:
// vite.config.js
export default { base: './' }
// webpack.config.js
output: { publicPath: './' }
They also emit <script type="module"> by default, which does not load from file://. Either target a classic-script output, or use a converter that serves bundled content over http://localhost — confirm which rather than assuming.
Single-page apps: use hash routing
A History-API router (/products/42) relies on the server rewriting unknown paths to index.html. No server, no rewrite, so deep links 404 inside the app. Hash routing (#/products/42) needs no server and works everywhere. Most routers switch with one option.
Storage
localStorage works in a WebView and survives restarts, but it is erased when the user clears the app's data and it is not backed up. Use it for preferences and caching. Anything the user would be upset to lose belongs on a server.
The five-second test
Open index.html directly from your file manager — not through a dev server. That is the environment the app gives your pages.
- Styled and complete?
- Images present?
- Console free of 404 and CORS errors?
- Links between pages working?
Before packaging
| Check | Because |
|---|---|
index.html at the root | It is the page the app opens |
| Relative paths everywhere | No site root exists in an app |
| Lowercase names, no spaces | Case-sensitive filesystem |
| Fonts and libraries local | Offline pages still render |
| Hash routing | No server to rewrite deep links |
| Viewport meta tag | Correct mobile layout |
| No sources, maps or secrets | Everything shipped can be extracted |