DEV Community

Antonio Ramírez
Antonio Ramírez

Posted on

# How I Deployed a Next.js 15 PWA on Cloudflare Pages with Zero Backend

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
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

Fix: create it manually first.

npx wrangler pages project create optimalplay
Enter fullscreen mode Exit fullscreen mode

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)
);
Enter fullscreen mode Exit fullscreen mode

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 }
};
Enter fullscreen mode Exit fullscreen mode

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',
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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',
});
Enter fullscreen mode Exit fullscreen mode

Middleware:

export default createMiddleware(routing);

export const config = {
  matcher: ['/((?!api|_next|_vercel|.*\\..*).*)'],
};
Enter fullscreen mode Exit fullscreen mode

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',
});
Enter fullscreen mode Exit fullscreen mode

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)