Ship Your Web Game to Android: Nuxt 4 + TypeScript + TWA

Turn your Nuxt 4 web game into a Play-Store-ready Android app with Trusted Web Activity (TWA). We’ll wire up a PWA with @vite-pwa/nuxt, verify quality criteria, and generate an Android app with Bubblewrap.
Tags: Nuxt 4, TypeScript, TWA, Android, PWA, @vite-pwa/nuxt
Time to read: 12 min
Why TWA (Trusted Web Activity)?
TWA lets your installed Android app run your PWA in full-screen Chrome with no URL bar, using your own signing key and Digital Asset Links for verification. You get native install, distribution via Play, and keep your web codebase.
Recent improvements in the Chrome/TWA stack make the flow smoother:
- Modern manifest-driven splash screen and theme integration.
- Stricter, clearer PWA quality criteria (offline fallback, maskable icons) with better tooling feedback.
- Stable workflow with Bubblewrap + Android Studio (AGP on JDK 17).
Prerequisites
- Node.js 18.18+ or 20+
- Java JDK 17
- Android Studio (with Android SDK Platform 34+ and Build Tools installed)
- A physical Android device or emulator with Google Play Services
- Familiarity with CLI and Git
- A public HTTPS domain for your game (required for Play release; can test on localhost/emulator first)
1) Scaffold a Nuxt 4 + TypeScript project
If you already have a Nuxt 4 game, skip to PWA setup.
npx nuxi@latest init nuxt4-twa-game
cd nuxt4-twa-game
npm install
Enable TypeScript strict mode and set a clean base in nuxt.config.ts:
export default defineNuxtConfig({
typescript: { strict: true },
compatibilityDate: '2024-11-01', // keep Nuxt/Vite behaviors consistent
})
Run the dev server:
npm run dev
Visit http://localhost:3000 to confirm it works.
2) Add a tiny Three.js “game” scene (optional but fun)
Create a minimal scene to verify full-screen rendering later on Android.
Install Three.js:
npm i three
Replace pages/index.vue with:
<script setup lang="ts">
import { onMounted, onBeforeUnmount, ref } from 'vue'
import * as THREE from 'three'
const canvas = ref<HTMLCanvasElement | null>(null)
let renderer: THREE.WebGLRenderer | undefined
let scene: THREE.Scene | undefined
let camera: THREE.PerspectiveCamera | undefined
let frameId = 0
onMounted(() => {
if (!canvas.value) return
const w = canvas.value.clientWidth
const h = canvas.value.clientHeight
renderer = new THREE.WebGLRenderer({ canvas: canvas.value, antialias: true, alpha: true })
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
renderer.setSize(w, h)
scene = new THREE.Scene()
camera = new THREE.PerspectiveCamera(60, w / h, 0.1, 100)
camera.position.z = 2
const cube = new THREE.Mesh(
new THREE.BoxGeometry(1, 1, 1),
new THREE.MeshStandardMaterial({ color: 0x00e5ff, metalness: 0.3, roughness: 0.4 }),
)
scene.add(cube)
const light = new THREE.DirectionalLight(0xffffff, 2)
light.position.set(1, 1, 1)
scene.add(light)
scene.add(new THREE.AmbientLight(0x404040, 0.6))
const onResize = () => {
if (!renderer || !camera || !canvas.value) return
const w2 = canvas.value.clientWidth
const h2 = canvas.value.clientHeight
renderer.setSize(w2, h2)
camera.aspect = w2 / h2
camera.updateProjectionMatrix()
}
window.addEventListener('resize', onResize)
const animate = () => {
if (!renderer || !scene || !camera) return
frameId = requestAnimationFrame(animate)
cube.rotation.x += 0.01
cube.rotation.y += 0.013
renderer.render(scene, camera)
}
animate()
})
onBeforeUnmount(() => {
cancelAnimationFrame(frameId)
renderer?.dispose()
})
</script>
<template>
<ClientOnly>
<div class="wrap">
<canvas ref="canvas" class="game"></canvas>
</div>
</ClientOnly>
</template>
<style scoped>
.wrap {
width: 100%;
height: 100vh;
background: radial-gradient(ellipse at top, #0a0a0f, #020209);
}
.game {
width: 100%;
height: 100%;
display: block;
touch-action: none;
}
</style>
3) Make it a PWA with @vite-pwa/nuxt
Install the module:
npm i -D @vite-pwa/nuxt
Add it to nuxt.config.ts with a manifest that satisfies TWA/Play quality criteria (maskable icon, offline fallback, theme color).
export default defineNuxtConfig({
typescript: { strict: true },
modules: ['@vite-pwa/nuxt'],
pwa: {
registerType: 'autoUpdate',
manifest: {
id: '/?source=pwa',
name: 'Nuxt 4 Web Game',
short_name: 'NuxtGame',
description: 'A Nuxt 4 + Three.js web game shipped to Android via TWA.',
start_url: '/?source=twa',
scope: '/',
display: 'standalone',
background_color: '#0A0A0F',
theme_color: '#00E5FF',
categories: ['game', 'entertainment'],
icons: [
// regular icons
{ src: '/pwa-icon-192.png', sizes: '192x192', type: 'image/png' },
{ src: '/pwa-icon-512.png', sizes: '512x512', type: 'image/png' },
// maskable icon required for high-quality install UI
{ src: '/pwa-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
],
screenshots: [
{ src: '/screenshot-1.webp', sizes: '1280x720', type: 'image/webp' },
{ src: '/screenshot-2.webp', sizes: '1280x720', type: 'image/webp' },
],
},
workbox: {
navigateFallback: '/offline.html',
runtimeCaching: [
// Example: cache game assets
{
urlPattern: /\.(?:png|jpg|jpeg|webp|gif|svg|mp3|wav|ogg|mp4|webm|glb|gltf|bin|ttf|woff2)$/,
handler: 'CacheFirst',
options: {
cacheName: 'assets-cache',
expiration: { maxEntries: 200, maxAgeSeconds: 60 * 60 * 24 * 30 }, // 30 days
},
},
// API or JSON
{
urlPattern: ({ request }) => request.destination === 'document' || request.destination === '',
handler: 'NetworkFirst',
options: {
cacheName: 'pages-cache',
expiration: { maxEntries: 50, maxAgeSeconds: 60 * 60 * 24 * 7 }, // 7 days
},
},
],
},
devOptions: {
enabled: true, // SW in dev for easier validation
},
},
})
Add the required files under public/:
public/pwa-icon-192.pngpublic/pwa-icon-512.pngpublic/pwa-maskable-512.png(maskable safe area)public/offline.html(simple offline fallback page)- Optional:
public/screenshot-1.webp,public/screenshot-2.webp
Example public/offline.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Offline</title>
<style>
body { margin: 0; background: #0a0a0f; color: #cbd5e1; font-family: system-ui, sans-serif; display:flex; align-items:center; justify-content:center; height:100vh; }
.card { text-align:center; padding:24px; border:1px solid #1f2937; border-radius:12px; background:#0b1220; }
a { color:#00e5ff; }
</style>
</head>
<body>
<div class="card">
<h1>Offline</h1>
<p>Your game is unavailable. Check your connection and try again.</p>
<p><a href="/">Retry</a></p>
</div>
</body>
</html>
Verify locally:
npm run dev
# or production-like
npm run build && npm run preview
Check Chrome DevTools > Application:
- Manifest loads with icons, theme_color.
- Service worker registered.
- App is installable.
- Offline page works when simulating “Offline”.
4) Deploy your game to HTTPS
TWA for release requires a publicly reachable HTTPS origin. You can use any host (Vercel, Netlify, Firebase Hosting, your own Nginx). Example with static hosting via Nitro output:
# Build and preview
npm run build
npm run preview
For a quick Firebase Hosting deploy (optional):
- Install and log in:
npm i -g firebase-tools firebase login - Initialize and deploy:
firebase init hosting # Public directory: .output/public # SPA rewrite: Yes firebase deploy
Ensure your site is live at something like https://game.example.com and that:
- https://game.example.com/manifest.webmanifest is available (served by @vite-pwa/nuxt).
- https://game.example.com/offline.html loads.
- The PWA installs on Android Chrome.
5) Generate your Android app with Bubblewrap (TWA)
Install Bubblewrap CLI:
npm i -g @bubblewrap/cli
# or use npx:
# npx @bubblewrap/cli --help
Initialize a TWA project from your manifest:
bubblewrap init --manifest https://game.example.com/manifest.webmanifest
The wizard will:
- Parse your manifest (name, icons, colors).
- Ask for package ID (e.g., com.yourstudio.nuxtgame).
- Ask about signing keys. You can create one here or provide an existing keystore.
If you need to create a keystore manually:
keytool -genkeypair -v -storetype JKS \
-storepass YOUR_PASS -keypass YOUR_PASS \
-keystore upload-keystore.jks -alias upload \
-keyalg RSA -keysize 2048 -validity 10000 \
-dname "CN=Your Name, OU=Games, O=Your Studio, L=City, S=State, C=US"
Then re-run bubblewrap init and point it at upload-keystore.jks.
Build and install to a connected device:
# Build the APK using Gradle via Bubblewrap
bubblewrap build
# Install to device/emulator via adb
bubblewrap install
Open the app—it should be full-screen with your theme_color and splash experience derived from your manifest.
6) Set up Digital Asset Links (required)
TWA requires a verified relationship between the Android app and your domain. Create an assetlinks.json on the web side.
Get your app’s SHA-256 certificate fingerprint:
keytool -list -v -keystore upload-keystore.jks -alias upload \
-storepass YOUR_PASS -keypass YOUR_PASS | grep 'SHA-256'
Create public/.well-known/assetlinks.json in your Nuxt project with:
[
{
"relation": [ "delegate_permission/common.handle_all_urls" ],
"target": {
"namespace": "android_app",
"package_name": "com.yourstudio.nuxtgame",
"sha256_cert_fingerprints": [
"AA:BB:CC:DD:...:ZZ" // replace with your SHA-256 fingerprint
]
}
}
]
Deploy your site so it’s available at:
Reinstall/run the app; verified apps will hide the URL bar and enable full TWA mode.
Tip: You can also add "relation": ["delegate_permission/common.get_login_creds","delegate_permission/common.handle_all_urls"] if you use additional associations, but handle_all_urls is the main requirement.
7) Build an App Bundle for Play Store
Open the generated Android project (created by Bubblewrap) in Android Studio:
- File > Open > choose the Bubblewrap project folder.
- Build > Generate Signed Bundle / APK…
- Select Android App Bundle.
- Use your upload keystore and alias.
- Finish to generate a
.aab.
Upload the .aab to Google Play Console > Your app > Production (or Internal Testing) and follow the checks.
8) Quality checklist for Play approval
- PWA installability:
- Valid manifest with:
- name, short_name, start_url, scope
- icons including a 512x512 maskable icon
- theme_color and background_color
- A working service worker
- Valid manifest with:
- Offline fallback page:
offline.htmlregistered via WorkboxnavigateFallback
- HTTPS everywhere
- Responsive and full-screen friendly UI
- Be mindful of external navigations:
- Keep navigation within scope (
scope: '/') or handle external links withtarget="_blank"so you don’t unintentionally break the TWA.
- Keep navigation within scope (
- App content policy compliance, privacy policy URL (Play requirement), and game rating questionnaire.
9) Optional: fine-tune PWA experience for Android
- Add
display_override: ["fullscreen", "standalone"]in the manifest if you’d like Chrome to consider fullscreen where supported. - Provide multiple screenshots and short description for Play listing.
- Use
workboxruntime strategies to pre-cache heavy assets (textures, models, audio) and show a preloader on first load. - Handle back button: keep navigation in-app; avoid deep external redirects.
What you just shipped
- A Nuxt 4 + TypeScript web game that runs full screen on Android
- Fully installable PWA with offline fallback and maskable icon
- Play-Store-distributable Android app via TWA + Bubblewrap
Happy shipping—and may your FPS stay high and your bundle sizes low!





