Next.js 15 App Router + @supabase/ssr 魔法链接登录后地图标记消失的问题排查与最佳实践咨询
嘿,我仔细梳理了你的问题,这个场景在Next.js App Router结合Supabase SSR认证的项目里挺常见的,咱们一步步拆解根因,给你落地的修复方案和最佳实践。
核心症状复盘
- 匿名用户状态下,地图报告标记加载完全正常
- 魔法链接登录完成重定向后,首页的报告标记全部消失
- RLS策略已设置为全公开(
USING (true)),理论上匿名/登录用户应看到相同数据
你的怀疑点基本都命中了关键,咱们逐个验证+修复
1. 模块级Supabase单例确实是核心问题之一
你用createBrowserClient创建的全局单例,在Next.js App Router里会有状态污染风险:模块在服务器端会被缓存复用,不同用户请求可能共享同一个实例;而且登录后设置的session cookie,这个单例没法及时感知到新的会话状态,导致请求时携带的会话信息不对。
修复方案:替换全局单例为自定义Hook
对于客户端组件,用useMemo动态创建客户端,避免全局状态共享:
// 替换原模块级单例,改用自定义Hook import { createBrowserClient } from '@supabase/ssr'; import { useMemo } from 'react'; export function useSupabaseClient() { const supabase = useMemo(() => { return createBrowserClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, { auth: { storageKey: 'sb-vivipaese-auth' } } ); }, []); return supabase; }
之后在组件里用const supabase = useSupabaseClient()替代全局导入。
2. 缺少middleware.ts是会话同步的关键缺口
哪怕你的页面是纯客户端渲染(ssr: false),middleware.ts也是必须的——它的核心作用是在每个请求到来时自动刷新Supabase会话,确保客户端能拿到最新的session状态,同时维护session cookie的有效性。
修复方案:添加会话同步中间件
// src/middleware.ts import { createMiddlewareClient } from '@supabase/ssr'; import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; export async function middleware(request: NextRequest) { const response = NextResponse.next(); // 创建中间件专用Supabase客户端,自动绑定请求/响应的cookie const supabase = createMiddlewareClient({ request, response }); // 刷新会话,确保客户端能拿到最新的session状态 await supabase.auth.getSession(); return response; } // 配置需要应用中间件的路由(排除静态资源和API路由) export const config = { matcher: [ '/((?!_next/static|_next/image|favicon.ico|api).*)', ], };
3. 竞态条件:onAuthStateChange事件触发顺序问题
你提到onAuthStateChange会触发INITIAL_SESSION、SIGNED_IN等多个事件,确实可能出现竞态:比如初始INITIAL_SESSION触发时会话还未稳定,导致fetch到空数据,后续的SIGNED_IN事件又可能覆盖正确状态。
修复方案:优化状态变更与数据Fetch逻辑
// src/lib/AuthContext.tsx import { useSupabaseClient } from '@/lib/use-supabase-client'; export function AuthContextProvider({ children }: { children: React.ReactNode }) { const [currentUser, setCurrentUser] = useState<User | null>(null); const [userProfile, setUserProfile] = useState<Profile | null>(null); const [isAuthLoading, setIsAuthLoading] = useState(true); const supabase = useSupabaseClient(); useEffect(() => { const { data: { subscription } } = supabase.auth.onAuthStateChange(async (event, session) => { // 只处理明确的会话变更事件,跳过初始的空会话干扰 if (event === 'SIGNED_IN' || event === 'SIGNED_OUT' || event === 'USER_UPDATED') { const user = session?.user ?? null; setCurrentUser(user); setIsAuthLoading(false); if (user) { const { data } = await supabase .from('profiles') .select('*') .eq('id', user.id) .maybeSingle(); setUserProfile(data ?? null); } else { setUserProfile(null); } } }); return () => subscription.unsubscribe(); }, [supabase]); // 依赖动态创建的客户端实例 // ...其余上下文逻辑 }
调整报告Fetch的依赖与时机:
// src/components/CivicApp.tsx import { useSupabaseClient } from '@/lib/use-supabase-client'; export function CivicApp({ municipality }: { municipality: Municipality }) { const [points, setPoints] = useState<ReportPoint[]>([]); const { currentUser, isAuthLoading } = useAuth(); const supabase = useSupabaseClient(); useEffect(() => { if (isAuthLoading) return; async function loadReports() { const { data, error } = await supabase .from('reports') .select('*') .eq('municipality_slug', municipality.slug); if (error) { console.error('加载报告失败:', error); return; } if (data) { setPoints(data.map(normalizeReport)); } } loadReports(); }, [municipality.slug, isAuthLoading, currentUser, supabase]); // 依赖会话状态与客户端实例 // ...其余组件逻辑 }
4. 回调路由的简化优化
用Supabase官方的handleAuth统一处理所有回调类型,减少自定义逻辑的出错概率:
// src/app/auth/callback/route.ts import { createSupabaseServerClient } from '@/lib/supabase-server'; import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; export async function GET(request: NextRequest) { const supabase = await createSupabaseServerClient(); const { searchParams, origin } = new URL(request.url); // 统一处理OTP、PKCE等所有回调类型 const { error } = await supabase.auth.handleAuthCallback(origin, searchParams); if (error) { return NextResponse.redirect(`${origin}/?error=auth_callback_failed`); } return NextResponse.redirect(origin); }
最终最佳实践总结
- 拒绝模块级Supabase单例:客户端用
useMemo包裹的Hook创建实例,服务器端/路由用createSupabaseServerClient按需创建 - 必须添加
middleware.ts:它是维护会话同步的核心,确保客户端与服务器端会话状态一致 - 优化状态变更依赖:避免竞态条件,确保数据Fetch只在会话状态稳定后执行
- 用官方方法处理认证回调:减少自定义逻辑的出错风险
你可以按照这个顺序逐步调整代码,应该就能解决登录后报告消失的问题。如果还有疑问,可以在浏览器控制台执行supabase.auth.getSession(),检查登录后是否能拿到有效会话,这是快速排查的关键。




