December 23, 2025 · 12 min

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

Package a Nuxt 4 web game as a native Android app using Trusted Web Activity (TWA) and ship it to the Play Store.

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

nuxt-android-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.png
  • public/pwa-icon-512.png
  • public/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:

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.

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
  • Offline fallback page:
    • offline.html registered via Workbox navigateFallback
  • HTTPS everywhere
  • Responsive and full-screen friendly UI
  • Be mindful of external navigations:
    • Keep navigation within scope (scope: '/') or handle external links with target="_blank" so you don’t unintentionally break the TWA.
  • 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 workbox runtime 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!