Next.js 16的 App Router、PWA与 Supabase SSR:移动端/作为PWA时无限加载(很可能是手动的Service Worker缓存了RSC/Flight)
应用地址: https://pharmagoli-loyalty.vercel.app/
背景
我在Vercel上使用Supabase Auth/RLS的 Next.js 16(App Router)+ React + TypeScript应用。桌面浏览器基本没问题(没有控制台错误),但在移动端(尤其是已安装的PWA)我经常遇到无限加载/空白屏幕。有时登录页在多次刷新后才出现;有时提交凭据也无效。
我没有使用 next-pwa,因为Next.js 16 Turbopack + next-pwa(webpack插件)不兼容。相反,我在 /public/sw.js 使用一个手动的service worker。
预期
- 移动端浏览器和已安装的PWA能稳定加载
- 登录稳定工作并正确重定向(没有无限加载)
实际
- 移动端/PWA常常卡在无限加载/空白屏
- 登录不稳定,需多次刷新
- 桌面端看起来正常
中间件(Supabase SSR认证门)
我用 @supabase/ssr 中间件配合 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).*)'],
}
手动service worker(public/sw.js)
我觉得这是罪魁祸首。它:
- 处理导航请求(
request.mode === 'navigate')时,采用网络优先+ 离线回退(不缓存) - 使用CacheFirst缓存
/_next/static/* - 使用StaleWhileRevalidate缓存图片/图标
- 对“其他一切”则使用NetworkFirst,设定3s超时,并将响应缓存在页面缓存中
// Everything else → NetworkFirst (3s timeout) and cache into PAGES_CACHE
// (full file available if needed; this is the key part)
在移动端/PWA上,我怀疑Next.js App Router的内部请求(RSC/Flight/data、text/x-component、诸如 RSC / Next-Router-State-Tree 等头信息)被视为“其他一切”并被缓存。这可能导致跨部署出现陈旧/不匹配的负载,并引发无限加载状态。
问题
在使用手动SW时,针对 Next.js 16 App Router 应用(尤其是带认证的),“推荐的service worker策略”是什么?
具体来说:
- App Router的内部请求(RSC/Flight/data)是否应被视为 NetworkOnly,永不缓存?我如何在SW中可靠检测它们(
RSC头、accept: text/x-component等等)? - 缓存除了
/_next/static/*和图标之外的任何内容是否安全?对于非静态请求,是否应完全移除“NetworkFirst+缓存”? - 是否存在Supabase SSR中间件(cookies/会话)与PWA/SW之间的已知交互,可能导致移动端无限加载?
如有帮助,我可以粘贴完整的 sw.js 和一个移动网络追踪(请求是否被缓存/302循环/获取卡顿)。
/**
* 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)
})
)
})
解决方案
很可能是你的service worker干扰了App Router的 RSC/Flight请求或认证重定向。
如果你使用Next.js 16(App Router),你不应缓存或拦截任何动态请求。
- 导航请求
- RSC/Flight请求(text/x-component、RSC、Next-Router-State-Tree头部)
- 受认证保护的路由
- API调用
即使在SW内部使用“NetworkFirst”或导航请求 fetch(),也可能破坏重定向处理(来自中间件的302),并导致Suspense卡顿,从而出现无限加载/空白屏,这在移动端/PWA中更常见。
对于App Router + Supabase SSR,请将service worker保持尽量简洁。
- 仅缓存
/_next/static/*(CacheFirst) - 可选地缓存图片/图标
- 让其他所有请求直接走网络(不要拦截)
在已认证的SSR应用中,将Service Worker仅作为资源缓存是最安全的做法。