<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Antonio Ramírez</title>
    <description>The latest articles on DEV Community by Antonio Ramírez (@ajramirezdev).</description>
    <link>https://dev.to/ajramirezdev</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3946345%2Fc7265d0b-94d2-437c-bfba-b540dad599c8.png</url>
      <title>DEV Community: Antonio Ramírez</title>
      <link>https://dev.to/ajramirezdev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ajramirezdev"/>
    <language>en</language>
    <item>
      <title># How I Deployed a Next.js 15 PWA on Cloudflare Pages with Zero Backend</title>
      <dc:creator>Antonio Ramírez</dc:creator>
      <pubDate>Fri, 22 May 2026 15:18:24 +0000</pubDate>
      <link>https://dev.to/ajramirezdev/-how-i-deployed-a-nextjs-15-pwa-on-cloudflare-pages-with-zero-backend-iha</link>
      <guid>https://dev.to/ajramirezdev/-how-i-deployed-a-nextjs-15-pwa-on-cloudflare-pages-with-zero-backend-iha</guid>
      <description>&lt;p&gt;I just shipped &lt;a href="https://optimalplay.pages.dev" rel="noopener noreferrer"&gt;OptimalPlay&lt;/a&gt;, 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.&lt;/p&gt;

&lt;p&gt;Here's the technical breakdown of the decisions that made it work — and the ones that almost didn't.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Next.js 15&lt;/strong&gt; (App Router) — framework&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare Pages&lt;/strong&gt; via &lt;code&gt;@cloudflare/next-on-pages&lt;/code&gt; — hosting&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Web Workers&lt;/strong&gt; — Monte Carlo simulations off the main thread&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dexie&lt;/strong&gt; (IndexedDB wrapper) — local-only session storage&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;next-intl&lt;/strong&gt; — ES/EN i18n&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tailwind CSS 3.4&lt;/strong&gt; + shadcn/ui — UI&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The constraint that shaped every decision: &lt;strong&gt;zero server calls at runtime&lt;/strong&gt;. All math runs in the browser, all storage is local, Cloudflare Web Analytics is the only external beacon (anonymous, no cookies).&lt;/p&gt;




&lt;h2&gt;
  
  
  Deploying Next.js 15 on Cloudflare Pages
&lt;/h2&gt;

&lt;p&gt;This was the trickiest part. Cloudflare Pages doesn't run Node.js — it runs the &lt;strong&gt;Workers runtime&lt;/strong&gt;, which is a V8 isolate with a subset of Web APIs. &lt;code&gt;@cloudflare/next-on-pages&lt;/code&gt; bridges the gap by transpiling Next.js edge routes into Workers-compatible bundles.&lt;/p&gt;

&lt;h3&gt;
  
  
  The build pipeline
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm &lt;span class="nb"&gt;exec &lt;/span&gt;next-on-pages
npx wrangler pages deploy .vercel/output/static &lt;span class="nt"&gt;--project-name&lt;/span&gt; optimalplay &lt;span class="nt"&gt;--branch&lt;/span&gt; main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;next-on-pages&lt;/code&gt; calls &lt;code&gt;vercel build&lt;/code&gt; internally, then transforms the output into a Workers-compatible &lt;code&gt;_worker.js&lt;/code&gt;. The result lands in &lt;code&gt;.vercel/output/static&lt;/code&gt;, which wrangler deploys directly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Edge runtime is mandatory
&lt;/h3&gt;

&lt;p&gt;Every route that does anything dynamic needs &lt;code&gt;export const runtime = 'edge'&lt;/code&gt;. Without it, Next.js defaults to Node.js runtime and the build fails on Cloudflare.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/[locale]/blackjack/page.tsx&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;runtime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;edge&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The catch: &lt;strong&gt;edge runtime disables static generation for that page&lt;/strong&gt;. You'll see this warning in the build output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;⚠ Using edge runtime on a page currently disables static generation for that page
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a fully client-side app this is fine — you want dynamic rendering anyway since static pages on Cloudflare can't run Workers logic.&lt;/p&gt;

&lt;h3&gt;
  
  
  Creating the project before the first deploy
&lt;/h3&gt;

&lt;p&gt;Wrangler won't create the Pages project automatically on first deploy. You'll get:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Project not found [code: 8000007]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fix: create it manually first.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx wrangler pages project create optimalplay
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then deploy normally. After that, CI deploys work without issues.&lt;/p&gt;




&lt;h2&gt;
  
  
  Monte Carlo Simulations in Web Workers
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Worker instantiation
&lt;/h3&gt;

&lt;p&gt;The correct pattern for Next.js with Turbopack/Webpack:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;worker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Worker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./roulette.worker.ts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Memory cap on outcomes
&lt;/h3&gt;

&lt;p&gt;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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MAX_RAW_OUTCOMES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SimResult&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;stats&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;aggregator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;outcomes&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;iterations&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;MAX_RAW_OUTCOMES&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;sample&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;outcomes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;MAX_RAW_OUTCOMES&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;outcomes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;sampled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;iterations&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;MAX_RAW_OUTCOMES&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;iterationsCompleted&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The UI shows &lt;code&gt;meta.sampled = true&lt;/code&gt; when displaying results so the user knows they're seeing a sample.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reproducibility
&lt;/h3&gt;

&lt;p&gt;Same seed → same results, but only if &lt;code&gt;meta.cancelled === false&lt;/code&gt;. Cancelled simulations stop at a non-deterministic iteration count depending on the event loop, so bit-for-bit reproducibility only applies to completed runs.&lt;/p&gt;




&lt;h2&gt;
  
  
  Local-Only Storage with Dexie
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Schema
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OptimalPlayDB&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Dexie&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;settings&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Table&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;UserPrefs&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;journalEntries&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Table&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;JournalEntry&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;trainerSessions&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Table&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;TrainerSession&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;OptimalPlayDB&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;version&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;stores&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;journalEntries&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;++id, gameType, startedAt&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;trainerSessions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;++id, gameType, timestamp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;UUIDs over auto-increment for portability — if a user exports and reimports their data, auto-increment IDs cause collisions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Edge case: Dexie + Next.js SSR
&lt;/h3&gt;

&lt;p&gt;Dexie accesses &lt;code&gt;window.indexedDB&lt;/code&gt; on import. In Next.js this breaks SSR/edge rendering. Fix: lazy-initialize the DB instance.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;OptimalPlayDB&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getDB&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nx"&gt;OptimalPlayDB&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OptimalPlayDB&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Never import the DB at module level in a file that gets server-rendered.&lt;/p&gt;




&lt;h2&gt;
  
  
  i18n with next-intl on Cloudflare
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;next-intl&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;routing.ts&lt;/code&gt; config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;routing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defineRouting&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;locales&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;es&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;defaultLocale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;es&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Middleware:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;createMiddleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;routing&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;matcher&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/((?!api|_next|_vercel|.*&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s1"&gt;..*).*)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The matcher pattern is important — without excluding &lt;code&gt;_next&lt;/code&gt; and static assets, the middleware runs on every static file request and adds unnecessary latency on Cloudflare's edge.&lt;/p&gt;




&lt;h2&gt;
  
  
  PWA Setup with Serwist
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;@serwist/next&lt;/code&gt; handles the service worker. The config in &lt;code&gt;next.config.ts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;withSerwist&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createSerwist&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;swSrc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;app/sw.ts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;swDest&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;public/sw.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cloudflare Pages serves &lt;code&gt;public/sw.js&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;One gotcha: the &lt;code&gt;sw.js&lt;/code&gt; file must be served from the root of the origin (&lt;code&gt;/sw.js&lt;/code&gt;), not from a subdirectory. Cloudflare Pages does this correctly by default when the file is in &lt;code&gt;public/&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Lighthouse scores after deploy: PWA ✓, Performance 91, Accessibility 94, Best Practices 96.&lt;/p&gt;

&lt;p&gt;The full source isn't public yet, but the app is live at &lt;strong&gt;&lt;a href="https://optimalplay.pages.dev" rel="noopener noreferrer"&gt;https://optimalplay.pages.dev&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;If you're building something similar or have questions about the next-on-pages setup, happy to answer in the comments.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Feedback form (5 questions, 2 min): &lt;a href="https://forms.gle/PwAz6rqG7xBCNz5t8" rel="noopener noreferrer"&gt;https://forms.gle/PwAz6rqG7xBCNz5t8&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>cloudflare</category>
      <category>pwa</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
