I just shipped OptimalPlay, a progressive web app that teaches casino math (expected value, variance, Monte Carlo simulations) entirely client-side. No backend, no database, no data leaving the device.
Here's the technical breakdown of the decisions that made it work — and the ones that almost didn't.
The Stack
- Next.js 15 (App Router) — framework
-
Cloudflare Pages via
@cloudflare/next-on-pages— hosting - Web Workers — Monte Carlo simulations off the main thread
- Dexie (IndexedDB wrapper) — local-only session storage
- next-intl — ES/EN i18n
- Tailwind CSS 3.4 + shadcn/ui — UI
The constraint that shaped every decision: zero server calls at runtime. All math runs in the browser, all storage is local, Cloudflare Web Analytics is the only external beacon (anonymous, no cookies).
Deploying Next.js 15 on Cloudflare Pages
This was the trickiest part. Cloudflare Pages doesn't run Node.js — it runs the Workers runtime, which is a V8 isolate with a subset of Web APIs. @cloudflare/next-on-pages bridges the gap by transpiling Next.js edge routes into Workers-compatible bundles.
The build pipeline
pnpm exec next-on-pages
npx wrangler pages deploy .vercel/output/static --project-name optimalplay --branch main
next-on-pages calls vercel build internally, then transforms the output into a Workers-compatible _worker.js. The result lands in .vercel/output/static, which wrangler deploys directly.
Edge runtime is mandatory
Every route that does anything dynamic needs export const runtime = 'edge'. Without it, Next.js defaults to Node.js runtime and the build fails on Cloudflare.
// app/[locale]/blackjack/page.tsx
export const runtime = 'edge';
The catch: edge runtime disables static generation for that page. You'll see this warning in the build output:
⚠ Using edge runtime on a page currently disables static generation for that page
For a fully client-side app this is fine — you want dynamic rendering anyway since static pages on Cloudflare can't run Workers logic.
Creating the project before the first deploy
Wrangler won't create the Pages project automatically on first deploy. You'll get:
Project not found [code: 8000007]
Fix: create it manually first.
npx wrangler pages project create optimalplay
Then deploy normally. After that, CI deploys work without issues.
Monte Carlo Simulations in Web Workers
Running 1,000,000 roulette spins on the main thread freezes the UI. Web Workers solve this, but Next.js + Webpack makes the setup non-obvious.
Worker instantiation
The correct pattern for Next.js with Turbopack/Webpack:
const worker = new Worker(
new URL('./roulette.worker.ts', import.meta.url)
);
This tells the bundler to treat the file as a separate chunk. Avoid string paths — they don't get picked up by the bundler and fail silently at runtime.
Memory cap on outcomes
Storing 1,000,000 result objects in JS will OOM on mid-range Android devices. The solution: hard-cap the raw outcomes array and use statistical sampling above the threshold.
const MAX_RAW_OUTCOMES = 10_000;
const result: SimResult = {
stats: aggregator(outcomes),
raw: iterations > MAX_RAW_OUTCOMES ? sample(outcomes, MAX_RAW_OUTCOMES) : outcomes,
meta: { sampled: iterations > MAX_RAW_OUTCOMES, iterationsCompleted }
};
The UI shows meta.sampled = true when displaying results so the user knows they're seeing a sample.
Reproducibility
Same seed → same results, but only if meta.cancelled === false. Cancelled simulations stop at a non-deterministic iteration count depending on the event loop, so bit-for-bit reproducibility only applies to completed runs.
Local-Only Storage with Dexie
The app has a session journal and a Blackjack trainer that tracks accuracy over time. Both are IndexedDB via Dexie, with no sync, no auth, no server.
Schema
class OptimalPlayDB extends Dexie {
settings!: Table<UserPrefs>;
journalEntries!: Table<JournalEntry>;
trainerSessions!: Table<TrainerSession>;
constructor() {
super('OptimalPlayDB');
this.version(1).stores({
settings: 'id',
journalEntries: '++id, gameType, startedAt',
trainerSessions: '++id, gameType, timestamp',
});
}
}
UUIDs over auto-increment for portability — if a user exports and reimports their data, auto-increment IDs cause collisions.
Edge case: Dexie + Next.js SSR
Dexie accesses window.indexedDB on import. In Next.js this breaks SSR/edge rendering. Fix: lazy-initialize the DB instance.
let db: OptimalPlayDB | null = null;
export function getDB(): OptimalPlayDB {
if (!db) db = new OptimalPlayDB();
return db;
}
Never import the DB at module level in a file that gets server-rendered.
i18n with next-intl on Cloudflare
next-intl works well with the App Router but needs a middleware that runs on every request to detect locale. On Cloudflare, middleware runs as a Worker — edge runtime, no Node.js APIs.
The routing.ts config:
export const routing = defineRouting({
locales: ['es', 'en'],
defaultLocale: 'es',
});
Middleware:
export default createMiddleware(routing);
export const config = {
matcher: ['/((?!api|_next|_vercel|.*\\..*).*)'],
};
The matcher pattern is important — without excluding _next and static assets, the middleware runs on every static file request and adds unnecessary latency on Cloudflare's edge.
PWA Setup with Serwist
@serwist/next handles the service worker. The config in next.config.ts:
const withSerwist = createSerwist({
swSrc: 'app/sw.ts',
swDest: 'public/sw.js',
});
Cloudflare Pages serves public/sw.js as a static asset, which is exactly what you want. The service worker scope covers the full origin and caches all static assets on first load, making the app fully offline after that.
One gotcha: the sw.js file must be served from the root of the origin (/sw.js), not from a subdirectory. Cloudflare Pages does this correctly by default when the file is in public/.
Results
The build output is 15 edge function routes + 143 static assets. First Load JS shared across all routes is ~104 KB. Individual route bundles are 138 B to 2 KB on top of that.
Lighthouse scores after deploy: PWA ✓, Performance 91, Accessibility 94, Best Practices 96.
The full source isn't public yet, but the app is live at https://optimalplay.pages.dev.
If you're building something similar or have questions about the next-on-pages setup, happy to answer in the comments.
Feedback form (5 questions, 2 min): https://forms.gle/PwAz6rqG7xBCNz5t8
Top comments (0)