DEV Community

Roberto Luna
Roberto Luna

Posted on

Fixing Service‑Worker Caching and Broken “/complexes” URLs in a Next.js Multi‑Page App

Fixing Service‑Worker Caching and Broken “/complexes” URLs in a Next.js Multi‑Page App

TL;DR: I disabled caching for the service‑worker script and corrected the base URL used in seven condo‑related pages. The changes stop the stale‑SW bug that was breaking API calls and restore the /complexes endpoint in production.


The Problem

During a week‑long sprint we started seeing intermittent 404 errors in Sentry coming from the condominios section of our app. The stack trace pointed to fetch calls like:

GET https://api.vibecoding.com/complexes?page=1&...
Status: 404
Enter fullscreen mode Exit fullscreen mode

At the same time, the UI kept showing stale data even after we deployed a new version. The culprit turned out to be the service worker (sw.js): it was being cached aggressively, so the browser kept serving an old version that still referenced an old API base URL (/complex instead of /complexes). Because the SW intercepts every network request, the broken URL propagated to all seven pages that list condos, fees, announcements, etc.


What I Tried First

My first instinct was to bust the cache by appending a query string (?v=123) to the SW import in next.config.mjs. That worked temporarily, but the next deploy regenerated a new hash and the problem resurfaced. I also tried adding a Cache-Control: no-store header in the Express middleware that serves sw.js, but Next.js serves static assets directly from the public folder, bypassing our server, so the header never reached the client.

Both approaches were band‑aids; the real fix required changing the caching strategy for the service worker itself and correcting the URL strings in the affected pages.


The Implementation

1. Stop caching sw.js in next.config.mjs

--- a/apps/web/next.config.mjs
+++ b/apps/web/next.config.mjs
@@
   async redirects() {
     return [
       // … existing redirects
     ];
   },
   skipTrailingSlashRedirect: true,
+  // sw.js must never be cached — it tells the browser if there is a new
+  // version of the app. Without this, the browser can keep an old SW that
+  // points to a dead API endpoint.
+  headers: async () => [
+    {
+      source: '/sw.js',
+      headers: [
+        {
+          key: 'Cache-Control',
+          value: 'no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0',
+        },
+      ],
+    },
+  ],
 };

 export default nextConfig;
Enter fullscreen mode Exit fullscreen mode

Why this works: Adding a headers callback tells Next.js to emit a Cache‑Control: no-store header only for /sw.js. The browser now treats the SW as a fresh resource on every load, forcing a re‑registration and eliminating stale‑SW behavior.

2. Clean up the service‑worker script (public/sw.js)

The original SW used a Cache‑First strategy for everything, including API calls. I switched to a Network‑First approach for /api/* routes and added an explicit skipWaiting() call to ensure the new SW takes control immediately.

--- a/apps/web/public/sw.js
+++ b/apps/web/public/sw.js
@@
-// Estrategia: Cache-First para assets, Network-First para API, Offline fallback
+// Strategy: Cache-First for static assets, Network-First for API routes,
+// and an offline fallback page.

-const CACHE_VE
+const CACHE_NAME = 'vibecoding-static-v2';
+const API_CACHE = 'vibecoding-api-v2';

 self.addEventListener('install', (e) => {
   e.waitUntil(
-    caches.open(CACHE_NAME).then((cache) => cache.addAll([
-      '/', '/index.html', '/styles.css', '/script.js',
-    ]))
+    caches.open(CACHE_NAME).then((cache) => cache.addAll([
+      '/', '/offline.html',
+    ]))
   );
   self.skipWaiting(); // <-- Force activation
 });

 self.addEventListener('fetch', (e) => {
-  // Cache‑First for everything
-  e.respondWith(
-    caches.match(e.request).then((resp) => resp || fetch(e.request))
-  );
+  const { request } = e;
+  const url = new URL(request.url);
+
+  // Network‑First for API calls
+  if (url.pathname.startsWith('/api/')) {
+    e.respondWith(
+      fetch(request)
+        .then((networkResp) => {
+          caches.open(API_CACHE).then((c) => c.put(request, networkResp.clone()));
+          return networkResp;
+        })
+        .catch(() => caches.match(request))
+    );
+    return;
+  }
+
+  // Cache‑First for static assets
+  e.respondWith(
+    caches.match(request).then((cached) => cached || fetch(request))
+  );
 });

-self.addEventListener('activate', (e) => {
-  e.waitUntil(
-    caches.keys().then((keyList) => Promise.all(keyList.map((key) => {
-      if (key !== CACHE_NAME) return caches.delete(key);
-    })))
-  );
-});
+self.addEventListener('activate', (e) => {
+  e.waitUntil(
+    caches.keys().then((keys) =>
+      Promise.all(
+        keys.map((key) => {
+          if (![CACHE_NAME, API_CACHE].includes(key)) {
+            return caches.delete(key);
+          }
+        })
+      )
+    )
+  );
+});
Enter fullscreen mode Exit fullscreen mode

Key points:

  • skipWaiting() ensures the newly fetched SW becomes active without waiting for a page reload.
  • Separate caches for static assets and API responses avoid polluting the asset cache with JSON payloads.
  • A fallback offline.html is now cached for true offline support.

3. Fix the broken /complexes endpoint in seven pages

All condo‑related pages built their fetch URLs using a helper getApiBase(). A typo (/complex instead of /complexes) slipped into the Complejos page and propagated to the others via copy‑paste. I corrected the string in each file.

--- a/apps/web/src/app/condominios/complejos/page.tsx
+++ b/apps/web/src/app/condominios/complejos/page.tsx
@@
-      const r = await fetch(`${getApiBase()}/complex?page=${p}&pageSize=${ps}`);
+      const r = await fetch(`${getApiBase()}/complexes?page=${p}&pageSize=${ps}`);
Enter fullscreen mode Exit fullscreen mode

The same change was applied to the other six pages (asambleas, avisos, certificados, cuotas, dashboard, proveedores). The diff pattern is identical, only the file path changes.

4. UI polish: modal close button and dark‑mode tokens

While fixing the functional bugs I also cleaned up a UI regression introduced by the new SW. The modal close button was rendered as a <div role="button"> without proper focus handling, causing accessibility issues and invisible contrast in dark mode.

--- a/apps/web/src/app/_components/ui.tsx
+++ b/apps/web/src/app/_components/ui.tsx
@@
-<div role="button" className="close-modal">
-  ✕
-</div>
+<button
+  type="button"
+  className="close-modal"
+  aria-label="Close modal"
+  style={{
+    background: 'transparent',
+    border: 'none',
+    color: 'var(--color-text-primary)', // token respects dark mode
+    opacity: 0.8,
+  }}
+>
+  ✕
+</button>
Enter fullscreen mode Exit fullscreen mode
  • The button now respects the CSS token `--color

Part of my Build in Public series — sharing the real process of building Building PlayaMXCRM from Playa del Carmen, México.

Repo: zaerohell/VS · 2026-08-11

#playadev #buildinpublic

Top comments (0)