Next.js 16 App Router PWA + Supabase SSR: infinite loading on mobile/PWA (manual service worker likely caching RSC/Flight)

2 weeks ago 16
ARTICLE AD BOX

App URL: https://pharmagoli-loyalty.vercel.app/

Context
I have a Next.js 16 (App Router) + React + TypeScript app on Vercel using Supabase Auth/RLS. Desktop browser is mostly fine (no console errors), but on mobile (and especially the installed PWA) I frequently get an infinite loading / blank screen. Sometimes the login page appears only after multiple refreshes; sometimes submitting credentials does nothing.

I’m not using next-pwa because Next.js 16 Turbopack + next-pwa (webpack plugin) is incompatible. Instead I use a manual service worker at /public/sw.js.

Expected

Mobile browser and installed PWA load reliably

Login works reliably and redirects correctly (no infinite loading)

Actual

Mobile/PWA often stuck on infinite loading / blank screen

Login unreliable until multiple refreshes

Desktop appears OK


Middleware (Supabase SSR auth gate)

I protect non-public routes with @supabase/ssr middleware using getSession():

import { createServerClient } from '@supabase/ssr' import { NextRequest, NextResponse } from 'next/server' const publicRoutes = ['/auth', '/api/auth', '/offline'] export async function middleware(request: NextRequest) { const { pathname } = request.nextUrl if (publicRoutes.some(route => pathname.startsWith(route))) return NextResponse.next() let response = NextResponse.next({ request: { headers: request.headers } }) const supabase = createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, { cookies: { getAll: () => request.cookies.getAll(), setAll(cookiesToSet) { cookiesToSet.forEach(({ name, value, options }) => { request.cookies.set({ name, value, ...options }) response.cookies.set({ name, value, ...options }) }) }, }, } ) const { data: { session }, error } = await supabase.auth.getSession() if (!session || error) { const loginUrl = new URL('/auth/login', request.url) loginUrl.searchParams.set('redirect', pathname) return NextResponse.redirect(loginUrl) } return response } export const config = { matcher: ['/((?!_next/static|_next/image|favicon\\.ico|manifest\\.json|icons|sw\\.js|workbox-.*\\.js|fallback-.*\\.js).*)'], }

Manual service worker (public/sw.js)

I think this is the culprit. It:

handles navigation (request.mode === 'navigate') with network + offline fallback (no caching)

caches /_next/static/* with CacheFirst

caches images/icons with StaleWhileRevalidate

for “everything else” it does NetworkFirst with 3s timeout and caches the response in a pages cache.

// Everything else → NetworkFirst (3s timeout) and cache into PAGES_CACHE // (full file available if needed; this is the key part)

On mobile/PWA, I suspect Next.js App Router internal requests (RSC/Flight/data, text/x-component, headers like RSC / Next-Router-State-Tree) are being treated as “everything else” and cached. That could cause stale/mismatched payloads across deploys and lead to an infinite loading state.


Question

What is the recommended service worker strategy for Next.js 16 App Router apps (especially with auth) when using a manual SW?

Specifically:

Should App Router internal requests (RSC/Flight/data) be treated as NetworkOnly and never cached? How do I reliably detect them in SW (RSC header, accept: text/x-component, etc.)?

Is it safe to cache anything beyond /_next/static/* and icons? Should I completely remove “NetworkFirst+cache” for non-static requests?

Any known interaction between Supabase SSR middleware (cookies/session) and PWA/SW that could cause mobile infinite loading?

If helpful I can paste the full sw.js and a mobile network trace (whether requests are cached / 302 loops / stalled fetches).

/** * Pharmagoli Loyalty — Service Worker * * Handles caching strategies, offline fallback, and push notifications. * This is a manual SW that replaces the broken next-pwa generated one * (next-pwa does not work with Turbopack / Next.js 16). * * Strategies: * - Navigation requests → NetworkFirst (fresh pages, offline fallback) * - Supabase / API calls → NetworkOnly (never cache) * - _next/static/* → CacheFirst (immutable, hashed filenames) * - Images / icons → StaleWhileRevalidate * - Everything else → NetworkOnly (no cache) */ const CACHE_VERSION = 'v4' const PAGES_CACHE = 'pharmagoli-pages-' + CACHE_VERSION const STATIC_CACHE = 'pharmagoli-static-' + CACHE_VERSION const ASSETS_CACHE = 'pharmagoli-assets-' + CACHE_VERSION var EXPECTED_CACHES = [PAGES_CACHE, STATIC_CACHE, ASSETS_CACHE] // ── Install ───────────────────────────────────────────────────────────────── self.addEventListener('install', function (event) { // Activate immediately — don't wait for the old SW to release clients self.skipWaiting() // Pre-cache the offline fallback page event.waitUntil( caches.open(PAGES_CACHE).then(function (cache) { return cache.add('/offline') }) ) }) // ── Activate ──────────────────────────────────────────────────────────────── self.addEventListener('activate', function (event) { event.waitUntil( Promise.all([ // Take control of all open tabs/windows immediately self.clients.claim(), // Delete ALL old caches — including stale next-pwa caches // (workbox-precache-*, next-static, next-image, etc.) caches.keys().then(function (keys) { return Promise.all( keys .filter(function (key) { return EXPECTED_CACHES.indexOf(key) === -1 }) .map(function (key) { return caches.delete(key) }) ) }), ]) ) }) // ── Fetch ─────────────────────────────────────────────────────────────────── self.addEventListener('fetch', function (event) { var request = event.request var url = new URL(request.url) // Only handle GET requests if (request.method !== 'GET') return // Ignore requests that browsers may send but SW cannot safely handle if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') return // ── NetworkOnly: Supabase API (auth, REST, RPC, realtime) ── if (url.hostname.indexOf('supabase.co') !== -1) return // ── NetworkOnly: internal Next.js API routes ── if (url.pathname.indexOf('/api/') === 0) return // ── Navigation requests (page loads) → Network with offline fallback ── // We do NOT cache navigation responses to avoid issues with redirects // (SW navigation requests use redirect:"manual" which produces opaque // redirect responses that cannot be cached or reused). // Only provide offline fallback when the network is completely down. if (request.mode === 'navigate') { event.respondWith( fetch(event.request).catch(function () { return caches.match('/offline').then(function (offlinePage) { return offlinePage || new Response( '<!DOCTYPE html><html><body><h1>Offline</h1><p>Verifica la connessione.</p></body></html>', { status: 200, headers: { 'Content-Type': 'text/html' } } ) }) }) ) return } // ── Static assets (_next/static/) → CacheFirst (immutable hashed URLs) ── if (url.pathname.indexOf('/_next/static/') === 0) { event.respondWith( caches.match(request).then(function (cached) { if (cached) return cached return fetch(request).then(function (response) { if (response.ok) { var clone = response.clone() caches.open(STATIC_CACHE).then(function (cache) { cache.put(request, clone) }) } return response }).catch(function () { return new Response('', { status: 408 }) }) }) ) return } // ── Images & icons → StaleWhileRevalidate ── if (url.pathname.indexOf('/_next/image') === 0 || url.pathname.indexOf('/icons/') === 0) { event.respondWith( caches.match(request).then(function (cached) { var networkFetch = fetch(request) .then(function (response) { if (response.ok) { var clone = response.clone() caches.open(ASSETS_CACHE).then(function (cache) { cache.put(request, clone) }) } return response }) .catch(function () { return cached }) return cached || networkFetch }) ) return } // ── Everything else → NetworkOnly (no cache) ── event.respondWith( fetch(request).catch(function () { return new Response('Offline', { status: 503 }) }) ) }) // ── Push received ─────────────────────────────────────────────────────────── self.addEventListener('push', function (event) { if (!event.data) return var payload try { payload = event.data.json() } catch (e) { payload = { title: 'Pharmagoli', body: event.data.text(), url: '/customer/dashboard' } } var title = payload.title || 'Pharmagoli' var body = payload.body || '' var url = payload.url || '/customer/dashboard' var data = payload.data || {} event.waitUntil( self.registration.showNotification(title, { body: body, icon: '/icons/icon-192.png', badge: '/icons/icon-192.png', data: { url: url, ...data }, vibrate: [200, 100, 200], requireInteraction: false, }) ) }) // ── Notification click → open / focus app ─────────────────────────────────── self.addEventListener('notificationclick', function (event) { event.notification.close() var targetUrl = (event.notification.data && event.notification.data.url) || '/customer/dashboard' event.waitUntil( clients .matchAll({ type: 'window', includeUncontrolled: true }) .then(function (clientList) { for (var i = 0; i < clientList.length; i++) { var client = clientList[i] if ('focus' in client) { client.focus() if ('navigate' in client) client.navigate(targetUrl) return } } return clients.openWindow(targetUrl) }) ) })
Read Entire Article