使用Next.js App Router的 @supabase/ssr:通过魔法链接登录后,地图标记会消失

前端开发 2026-07-11

我正在用 @supabase/ssr 构建一个基于 Next.js 15 App Router 的应用,用于身份认证。该应用在地图上显示带地理参考的报告。

匿名用户 来说,一切都正常——报告如期加载。然而,用户通过 魔术链接(邮箱中的一次性验证码) 登录后,在完成登录后的重定向后,地图上的报告消失。

在Supabase的 Discord社区里,有用户建议在SSR环境中使用一个 模块级Supabase单例 可能会带来问题。我正在尝试找出根本原因以及修复的正确模式。


认证流程

  1. 用户请求魔术链接:

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 依赖,在登录后重新获取

疑点

  1. 缺少中间件(Missing middleware)
    @supabase/ssr 文档建议使用 middleware.ts 来刷新会话。
    我没有一个——这会不会阻止浏览器端在回调路由中看到设好的会话?
  2. 竞态条件
    onAuthStateChange 会触发多个事件(INITIAL_SESSIONSIGNED_IN),从而触发多次获取。
    后续的获取是否可能用空数据覆盖状态?
  3. 模块级单例
    Supabase客户端在导入时就已创建。
    这会不会阻止它在登录后获取到新的会话Cookie?

问题

  1. 在Next.js App Router中,模块级 createBrowserClient 单例 是否存在问题?
  2. 即使是完全客户端渲染的应用(ssr: false),middleware.ts 也需要吗?
  3. 在App Router中使用 @supabase/ssr 搭配魔术链接认证的正确模式是什么,确保在登录前后数据都可见?

技术栈

  • Next.js 15(App Router)
  • @supabase/ssr v0.5
  • TypeScript

解决方案

已解决——将解决方案发布给遇到此类问题的朋友们。

有两个根本原因:

  1. 浏览器端的Supabase单例实现不当:createBrowserClient的实例化方式可能产生多个实例或状态不一致。Supabase JS v2使用内部异步锁来序列化令牌操作——如果客户端不是严格的单例,这些锁可能悄悄死锁,导致登录后认证流程无法完成。修复:确保createBrowserClient在浏览器上下文中只被调用一次,并在整个应用中复用。

  2. 在onAuthStateChange内部调用getSession():在onAuthStateChange回调中调用getSession() 会在异步认证流程中造成循环依赖。回调在会话变化时触发,但在其中再次调用getSession() 会触发另一个内部锁的获取——可能无限期阻塞。修复:从onAuthStateChange内移除对getSession() 的调用。回调已经作为参数接收了会话——直接使用该会话即可。

  3. 登录后地图标记未重新加载(额外问题):用于获取并呈现地图标记的useEffect在 SIGNED_IN事件后没有重新触发。修复是在获取标记的effect的依赖数组中加入currentUser?.id,以便认证用户变化时重新执行。

感谢Supabase的 @silentworks早期指出单例问题。

站内所有文章版权归属LeftHeroAI导航站,无授权禁止任何主体转载、抄袭、复制内容,亦不得私自架设镜像站点。一经侵权,本站将通过法律途径追责。

相关文章