# How I Built an Offline-First Party Game with Next.js and Capacitor (and Shipped It to Google Play)

> Source: <https://dev.to/syedahmedali_dev/how-i-built-an-offline-first-party-game-with-nextjs-and-capacitor-and-shipped-it-to-google-play-2i9a>
> Published: 2026-11-01 12:46:50+00:00

I recently shipped **Imposter Word**, a pass-the-phone social deduction game, to Google Play. Most players get a secret word. One or more imposters get only a vague hint. Everyone gives clues, argues, votes, and tries to catch the imposter before the imposter works out the word.

It's a free game, built with Next.js, TypeScript and Capacitor. The interesting part wasn't the game rules. It was making a web stack behave like a native app that works on a train with no signal, then getting it through Google's release process.

This post covers the architecture decisions that mattered, the bugs that cost me the most time, and what I'd do differently.

**Try it:** [Imposter Word on Google Play](https://play.google.com/store/apps/details?id=com.hafizsyedahmedali.imposterword) · [Source on GitHub](https://github.com/HafizSyedAhmedAli/Imposter-Word-Game)

A party game gets played in cars, cafés, hostels and living rooms with bad Wi-Fi. That gave me three rules:

`gemini-2.5-flash-lite`) for word and hint generation, called only from a server route
The game engine never talks to the AI or to IndexedDB directly. It only knows a small interface:

```
export interface WordProvider {
  generateRoundContent(
    category: Category,
    difficulty: Difficulty,
    options?: {
      signal?: AbortSignal;
      excludeWords?: string[];
      language?: GameLanguage;
    },
  ): Promise<GeneratedRoundContent>;
}
```

There are three implementations, tried in order:

Here is the shape of the selection function, simplified:

```
async function getRoundContent(category, difficulty, language, signal) {
  if (navigator.onLine) {
    try {
      const content = await aiProvider.generateRoundContent(category, difficulty, {
        signal,
        excludeWords: getRecentWordText(),
        language,
      });
      void cacheAiWord(content); // best-effort, never blocks the round
      return content;
    } catch {
      if (signal?.aborted) throw new Error("Round preparation cancelled.");
    }
  }

  try {
    return await cacheProvider.generateRoundContent(category, difficulty, { language });
  } catch {
    // fall through to the bundled list
  }

  return fallbackProvider.generateRoundContent(category, difficulty, { language });
}
```

Two details made this feel solid:

The bundled fallback list deliberately uses generic terms (a "sequel" instead of a real movie title) so nothing depends on someone else's IP.

The Gemini key lives only in a Next.js route handler (`/api/round/generate`) and is never prefixed with `NEXT_PUBLIC_`, so it can't end up in the client bundle. The client sends only the category, the difficulty and a list of recent words to avoid. The server returns a validated `{ word, hint }` pair, never the raw model output.

A few things I'd repeat:

`responseMimeType: "application/json"`, so there's no markdown to strip.

```
if (hint.toLowerCase().includes(word.toLowerCase())) {
  return { valid: false, reason: "Hint reveals the word." };
}
```

This was the hardest part, and the most educational.

This layer is for the web/PWA version. The native Android app skips the service worker entirely, because Capacitor already bundles the assets inside the app.

A service worker that only caches pages you've already visited isn't offline-first. If a player installs the app and then goes offline, screens they haven't opened yet would be missing. So the service worker precaches the full HTML for every route at install time:

``` js
const APP_ROUTES = [
  "/", "/setup", "/players", "/round", "/pass", "/game",
  "/voting", "/results", "/final-results", "/how-to-play",
  "/privacy", "/settings", "/statistics",
];
```

The service worker can't import TypeScript, so the client has its own copy of that list in `lib/app-routes.ts`. Two hand-maintained lists will drift, and they did once. So the build script that generates `sw.js` compares the two and **fails the build** if they differ. Adding a new screen and forgetting one of the lists is now impossible to ship.

The next bug was subtle. In the App Router, moving between screens with `<Link>` or `router.push` fetches an RSC payload in the background. Offline, that fetch fails, and the transition can hang or end on the browser's "site can't be reached" page. It doesn't reliably fall back to a normal page load.

The fix: when the device is offline and the target is a known app route, skip the router and force a real document navigation, which the service worker can answer from its precache:

```
export function navigateInternal(target: string, replace = false): boolean {
  if (!isOffline() || !isKnownAppRoute(target)) return false;

  hardNavigateListeners.forEach((listener) => listener());

  if (replace) window.location.replace(target);
  else window.location.assign(target);
  return true;
}
```

Online, this never runs, so normal client-side navigation is untouched.

A side effect: a hard navigation tears down the page, including the ambient menu music. The `hardNavigateListeners` hook lets the music controller note that the music was playing, so the reloaded page resumes it quickly as a continuation instead of replaying the fade-in from silence.

`navigator.onLine` lies inside an Android WebView
On the web, `navigator.onLine` is good enough for a status badge. Inside a Capacitor Android WebView, I saw it stay "online" after Wi-Fi and mobile data were both off. The fix was to use Capacitor's Network plugin on native, which asks Android's ConnectivityManager directly, and keep the browser events only for the web build:

```
// simplified
if (Capacitor.isNativePlatform()) {
  const { Network } = await import("@capacitor/network");
  Network.getStatus().then((s) => setOnline(s.connected));
  Network.addListener("networkStatusChange", (s) => setOnline(s.connected));
}
```

The same code ships as a web app and as an Android app, and they have different needs:

`/api/round/generate` works.`output: "export"`) inside the app. A static export can't contain a server route handler.
So the mobile build script temporarily renames `app/api` out of the way, runs `next build` with `NEXT_PUBLIC_BUILD_TARGET=capacitor`, and restores the folder in a `finally` block, whether or not the build succeeded. A failed build that leaves `app/api` renamed would silently break the next normal build, which is worse than the mobile build failing.

The native app has no server of its own, so it calls the deployed web instance through `NEXT_PUBLIC_API_BASE_URL`. That makes the route cross-origin from `https://localhost`, so it sets CORS headers. If the call fails for any reason, the app falls back to local words like everywhere else.

Everything personal stays in IndexedDB through Dexie: settings, statistics, achievements, and a cache of AI words. Two choices paid off:

`contentSource` on a round can then tell the two apart.`usageCount` and `lastUsedAt`, and selection prefers words that have never been used, then the least recently used. A long-lived install doesn't keep seeing the same few words.
Schema changes go through numbered Dexie migrations. When I added the Roman Urdu language option, existing rows were backfilled to `"english"`, and the read code also treats a missing language as English in case a migration is only partly applied.

For a game with lots of state (roles, votes, eliminations, ties, multiple imposters), the pure game logic was worth testing heavily. The rules live in plain TypeScript modules, with hundreds of Vitest unit tests covering role assignment, voting, elimination and results. Playwright covers the flows that need a browser, including an offline-fallback spec that aborts the AI request at the network layer and checks that a round still reaches the pass-the-phone screen from the bundled word list.

Building the app was the easy half. These cost me the most time:

**1. An optional signing config silently produces an unsigned bundle.** My Gradle config only signs the release build if `keystore.properties` exists. Without it, the build still succeeds, and Play Console then rejects the upload with "All uploaded bundles must be signed". If you gitignore your keystore config, check for it before you build.

```
def hasKeystore = rootProject.file("keystore.properties").exists()
// ...
release {
    if (hasKeystore) { signingConfig signingConfigs.release }
}
```

**2. `storeFile` is relative to the `app` module.** I wrote `app/my-key.jks` and Gradle looked in `app/app/`. The bare filename was right.

**3. Java 25 breaks the Gradle version Android uses.** Android Studio's bundled JDK was 25, and the build died with "Unsupported class file major version 69". Pointing Gradle at JDK 21 fixed it. Keep that path in a local, uncommitted setting, not in a file other people will clone.

**4. I committed my keystore.** Early on, a `git add .` pushed the release keystore and its password file to a public repo, because my `.gitignore` fix had never been saved on my real machine. I scrubbed the history with `git-filter-repo`, force-pushed, deleted the tag, and generated a brand-new key. This was only survivable because the app wasn't published yet. After release, a Play signing key is effectively permanent. Add the ignore rules first, and run `git status` before you commit.

**5. New personal accounts must run a closed test.** Google requires a number of testers (12 at the time) to stay opted in for 14 days before you can request production access. Start recruiting testers before your build is finished.

**6. The privacy policy must be real and reachable.** Play needs a public URL, and the Data safety form has to match what the app actually does. I made the policy a page inside the app (`/privacy`) so it ships with the product. My analytics sends only a handful of explicitly named, coarse events, with autocapture and session recording turned off, and never sends names, words or hints. The policy and the form disclose it anyway.

The game is free, with no account, and works offline: [Imposter Word on Google Play](https://play.google.com/store/apps/details?id=com.hafizsyedahmedali.imposterword). The code is on [GitHub](https://github.com/HafizSyedAhmedAli/Imposter-Word-Game).

If you've shipped a Capacitor app, I'd like to hear what bit you. And if you want a playable word game for your next road trip, grab three friends and give it a go.

*I'm Syed Ahmed Ali, a full-stack developer building web apps and AI-powered products. You can find me on [LinkedIn](https://www.linkedin.com/in/syed-ahmed-ali-625523307) and [my portfolio](http://syedahmedali.com/).*
