使用Next.js App Router的 @supabase/ssr:通过魔法链接登录后,地图标记会消失
我正在用 @supabase/ssr 构建一个基于 Next.js 15 App Router 的应用,用于身份认证。该应用在地图上显示带地理参考的报告。
对 匿名用户 来说,一切都正常——报告如期加载。然而,用户通过 魔术链接(邮箱中的一次性验证码) 登录后,在完成登录后的重定向后,地图上的报告消失。
在Supabase的 Discord社区里,有用户建议在SSR环境中使用一个 模块级Supabase单例 可能会带来问题。我正在尝试找出根本原因以及修复的正确模式。
认证流程
- 用户请求魔术链接:
signInWithOtp({
email,
options: { emailRedirectTo: '/auth/callback' }
})
2.用户点击链接 → 跳转到:
/auth/callback?token_hash=...&type=magiclink
(或用于PKCE的 ?code=...)
3.服务器端路由处理器:
- 交换令牌
- 设置会话Cookies
-
重定向到
/ -
在
/时,应用加载完毕 —— 但报告此时为空
相关代码
Supabase客户端(模块级单例)
// src/lib/supabase.ts
import { createBrowserClient } from '@supabase/ssr';
export const supabase = createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{ auth: { storageKey: 'sb-vivipaese-auth' } }
);
认证回调路由
// src/app/auth/callback/route.ts
export async function GET(request: NextRequest) {
const { searchParams, origin } = new URL(request.url);
const code = searchParams.get('code');
const tokenHash = searchParams.get('token_hash');
const type = searchParams.get('type');
const supabase = await createSupabaseServerClient();
// Flow A: OTP
if (tokenHash && type) {
const { error } = await supabase.auth.verifyOtp({
token_hash: tokenHash,
type: type as any
});
if (!error) return NextResponse.redirect(origin + '/');
}
// Flow B: PKCE
if (code) {
const { error } = await supabase.auth.exchangeCodeForSession(code);
if (!error) return NextResponse.redirect(origin + '/');
}
return NextResponse.redirect(origin + '/?error=auth_callback_failed');
}
认证上下文
// src/lib/AuthContext.tsx
useEffect(() => {
const { data: { subscription } } =
supabase.auth.onAuthStateChange(async (event, session) => {
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();
}, []);
报告获取
// src/components/CivicApp.tsx
useEffect(() => {
if (isAuthLoading) return;
async function load() {
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));
}
}
load();
}, [municipality.slug, isAuthLoading, currentUser?.id]);
行级安全策略(RLS)
报告完全公开:
CREATE POLICY "rls_reports_select_public"
ON public.reports
FOR SELECT
USING (true);
匿名用户和已认证用户都应该看到相同的数据。
我尝试过的做法
- 增加
isAuthLoading守卫以延迟获取 - 提前设置
setIsAuthLoading(false)以解锁获取 - 添加
currentUser?.id依赖,在登录后重新获取
疑点
- 缺少中间件(Missing middleware)
@supabase/ssr文档建议使用middleware.ts来刷新会话。
我没有一个——这会不会阻止浏览器端在回调路由中看到设好的会话? - 竞态条件
onAuthStateChange会触发多个事件(INITIAL_SESSION、SIGNED_IN),从而触发多次获取。
后续的获取是否可能用空数据覆盖状态? - 模块级单例
Supabase客户端在导入时就已创建。
这会不会阻止它在登录后获取到新的会话Cookie?
问题
- 在Next.js App Router中,模块级
createBrowserClient单例 是否存在问题? - 即使是完全客户端渲染的应用(
ssr: false),middleware.ts也需要吗? - 在App Router中使用
@supabase/ssr搭配魔术链接认证的正确模式是什么,确保在登录前后数据都可见?
技术栈
- Next.js 15(App Router)
@supabase/ssrv0.5- TypeScript
解决方案
已解决——将解决方案发布给遇到此类问题的朋友们。
有两个根本原因:
-
浏览器端的Supabase单例实现不当:createBrowserClient的实例化方式可能产生多个实例或状态不一致。Supabase JS v2使用内部异步锁来序列化令牌操作——如果客户端不是严格的单例,这些锁可能悄悄死锁,导致登录后认证流程无法完成。修复:确保createBrowserClient在浏览器上下文中只被调用一次,并在整个应用中复用。
-
在onAuthStateChange内部调用getSession():在onAuthStateChange回调中调用getSession() 会在异步认证流程中造成循环依赖。回调在会话变化时触发,但在其中再次调用getSession() 会触发另一个内部锁的获取——可能无限期阻塞。修复:从onAuthStateChange内移除对getSession() 的调用。回调已经作为参数接收了会话——直接使用该会话即可。
-
登录后地图标记未重新加载(额外问题):用于获取并呈现地图标记的useEffect在 SIGNED_IN事件后没有重新触发。修复是在获取标记的effect的依赖数组中加入currentUser?.id,以便认证用户变化时重新执行。
感谢Supabase的 @silentworks早期指出单例问题。