PAYATHON 2026

Next.js 使用 Supabase magic link 登录后地图标记消失,如何排查?

数据阿明

优先调整 onAuthStateChange 中直接 await Supabase 查询的写法,同时检查浏览器和服务端是否使用相同的 storageKey,以及认证回调是否将 cookies 写入了重定向响应。现有信息不足以确认唯一根因,也不能直接归咎于模块级单例或缺少 middleware。

createBrowserClient 在浏览器中复用实例是正常行为。middleware 主要负责维护服务端会话刷新,浏览器读取回调写入的 cookies 不必先经过 middleware。

先区分查询没有完成、返回空数组,还是地图没有渲染

这三种情况的排查方向不同。打开浏览器 Network,保留登录跳转前后的请求记录,检查 reports 查询:

  • 查询一直没有完成,或者根本没有发出:检查认证监听回调是否阻塞,以及 isAuthLoading 是否正常结束。
  • 请求成功,响应为 []:检查 municipality.slug、查询项目、实际数据库权限和策略。
  • 响应包含报告,但地图没有标记:检查 normalizeReport、坐标字段、地图图层更新,以及其他调用 setPoints 的位置。
  • 请求返回错误:根据状态码和 Supabase 错误内容处理,不要将错误当成空数据。

原代码中的 if (data) 也会接受 [],因为 JavaScript 的空数组为真值。因此,请求成功返回空数组时,确实可能执行 setPoints([]),清空地图标记。

将 profiles 查询移出认证监听回调

当前代码在 onAuthStateChange 的异步回调中,等待同一个 Supabase 客户端执行 profiles 查询。这种写法有认证锁等待或死锁的风险。Supabase 的故障排查说明记录了这一问题,但仅凭代码还不能断言它就是当前故障的原因。Supabase 查询挂起排查说明

让认证监听回调只同步更新状态,将资料查询放入独立 effect:

useEffect(() => {
  const {
    data: { subscription },
  } = supabase.auth.onAuthStateChange((_event, session) => {
    setCurrentUser(session?.user ?? null);
    setIsAuthLoading(false);
  });

  return () => subscription.unsubscribe();
}, []);

useEffect(() => {
  const userId = currentUser?.id;
  let cancelled = false;

  setUserProfile(null);
  if (!userId) return;

  async function loadProfile() {
    try {
      const { data, error } = await supabase
        .from('profiles')
        .select('*')
        .eq('id', userId)
        .maybeSingle();

      if (cancelled) return;
      if (error) {
        console.error('profiles query failed', error);
        return;
      }

      setUserProfile(data ?? null);
    } catch (error) {
      if (!cancelled) console.error('profiles query failed', error);
    }
  }

  void loadProfile();

  return () => {
    cancelled = true;
  };
}, [currentUser?.id]);

这里沿用原代码中的稳定 supabase 实例。组件文件需要处于 Client Component 边界内,isAuthLoading 的初始值应为 true。资料加载应有自己的状态,不必阻塞公开报告查询。

浏览器可以复用实例,服务端应按请求创建客户端

@supabase/ssr v0.5.0 的源码包含浏览器实例缓存逻辑:在浏览器环境且未禁用单例时,createBrowserClient 会复用已有实例。因此,浏览器模块级单例本身不是错误,也不能据此判断它无法读取新会话。v0.5.0 createBrowserClient 源码

使用时需要区分客户端和服务端:

  • 浏览器组件使用 createBrowserClient,复用稳定实例。
  • Route Handler、Server Action 等服务端代码使用 createServerClient,绑定当前请求的 cookies。
  • 不要把携带用户会话的服务端客户端保存为跨请求共享的模块级单例。

如果回调后客户端仍显示匿名状态,应先核对 cookie 配置和请求链路,无需在每次渲染时反复创建新实例。

统一 storageKey,并确认重定向响应携带 cookies

浏览器端显式配置了:

auth: { storageKey: 'sb-vivipaese-auth' }

服务端 createSupabaseServerClient() 的实现未提供。如果服务端仍使用默认存储键,两端就可能读写不同的会话 cookies。这一配置差异需要核实,不能视为已确认的事实。

可以保留自定义键,但浏览器、认证回调及后续 middleware 中创建的客户端必须采用一致配置;也可以统一恢复默认配置。排查期间不要同时保留多套创建客户端的方式。

下面的 Route Handler 示例直接将 cookies 写入最终返回的 NextResponse,适用于请求中携带 code,或邮件模板传入受支持的 token_hash 和 type 的情况:

// src/app/auth/callback/route.ts
import { createServerClient } from '@supabase/ssr';
import { NextRequest, NextResponse } from 'next/server';

export async function GET(request: NextRequest) {
  const url = new URL(request.url);
  const code = url.searchParams.get('code');
  const tokenHash = url.searchParams.get('token_hash');
  const type = url.searchParams.get('type');

  const response = NextResponse.redirect(new URL('/', url));

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      auth: { storageKey: 'sb-vivipaese-auth' },
      cookies: {
        getAll() {
          return request.cookies.getAll();
        },
        setAll(cookiesToSet) {
          cookiesToSet.forEach(({ name, value, options }) => {
            response.cookies.set(name, value, options);
          });
        },
      },
    }
  );

  let succeeded = false;

  if (code) {
    const { error } = await supabase.auth.exchangeCodeForSession(code);
    succeeded = !error;
    if (error) console.error('PKCE exchange failed', error.message);
  } else if (
    tokenHash &&
    (type === 'email' || type === 'magiclink')
  ) {
    const { error } = await supabase.auth.verifyOtp({
      token_hash: tokenHash,
      type,
    });
    succeeded = !error;
    if (error) console.error('OTP verification failed', error.message);
  }

  if (!succeeded) {
    response.headers.set(
      'Location',
      new URL('/?error=auth_callback_failed', url).toString()
    );
  }

  return response;
}

这个示例只处理当前问题中的邮件登录入口。type 必须与实际邮件模板和认证流程匹配,不要用 as any 无条件接受 URL 中的值。createServerClient 的 cookie 适配器需要正确实现读取和写入。v0.5.0 createServerClient 源码

在开发者工具中确认认证成功的回调响应带有 Set-Cookie,浏览器没有因 Domain、Path、Secure 等属性拒绝保存,并且后续访问 / 时携带了对应 cookies。

发送邮件时,建议明确使用本站的完整回调 URL,并在 Supabase 的允许重定向地址中配置该地址:

await supabase.auth.signInWithOtp({
  email,
  options: {
    emailRedirectTo: new URL('/auth/callback', window.location.origin).href,
  },
});

token_hash 路径还需要邮件模板生成对应链接;仅设置 emailRedirectTo 不能代替模板配置。PKCE 路径则需要回调能读取发起登录时保存的 code verifier。Supabase 邮件免密登录说明、服务端认证流程说明

ssr: false 是否仍需要 middleware.ts?

是否需要 middleware,应根据服务端是否依赖会话判断,不能只看地图组件是否设置了 ssr: false。

如果报告查询和登录状态维护都在浏览器中完成,服务端只在可写 cookies 的认证回调里交换凭据,那么浏览器读取回调写入的 cookies 时,不需要先经过 middleware。

如果 Server Components 或其他服务端逻辑需要持续读取有效会话,尤其是在读取 cookies 却无法写回刷新结果的场景,就需要通过 middleware 或等效机制维护刷新后的会话。刷新结果必须传递给后续服务端处理,同时写回浏览器。Supabase Next.js 服务端客户端指南

题目使用 Next.js 15,对应的文件名是 middleware.ts。缺少它可能影响服务端会话续期,但不能直接解释为什么公开报告查询在首次登录后变成空数组。

防止过期请求覆盖当前地图状态

认证状态或地区发生变化时,旧请求可能晚于新请求返回。可以用 effect 清理函数忽略旧结果:

useEffect(() => {
  if (isAuthLoading) return;

  let cancelled = false;
  const slug = municipality.slug;

  async function load() {
    try {
      const { data, error } = await supabase
        .from('reports')
        .select('*')
        .eq('municipality_slug', slug);

      if (cancelled) return;

      console.debug('reports result', {
        slug,
        rows: data?.length,
        errorCode: error?.code,
      });

      if (error) {
        console.error('reports query failed', error);
        return;
      }

      setPoints((data ?? []).map(normalizeReport));
    } catch (error) {
      if (!cancelled) console.error('reports query failed', error);
    }
  }

  void load();

  return () => {
    cancelled = true;
  };
}, [municipality.slug, isAuthLoading, currentUser?.id]);

这会忽略旧 effect 的返回结果,不会取消网络请求,也不会修复错误的查询条件或数据库权限。

多个认证事件并不必然造成多次报告查询。React 会比较依赖项;如果 currentUser?.id 和其他依赖没有变化,effect 不会仅因事件名称不同而重新执行。即便发生重复查询,在相同项目、条件、数据和访问权限下,也不应仅因重复执行就返回空数组。

如果确实返回 [],继续核对实际 RLS 和权限

提供的策略没有指定 TO,默认适用于 PUBLIC。单看这条 USING (true),它不会按用户身份过滤报告。还需要确认部署中的实际策略、是否存在 restrictive 策略,以及 anon、authenticated 的表访问权限。RLS 策略和 SQL 授权是不同层面的控制。Supabase RLS 说明

可以先进行只读检查:

SELECT policyname, permissive, roles, cmd, qual
FROM pg_policies
WHERE schemaname = 'public'
  AND tablename = 'reports';

SELECT
  has_table_privilege('anon', 'public.reports', 'SELECT')
    AS anon_can_select,
  has_table_privilege('authenticated', 'public.reports', 'SELECT')
    AS authenticated_can_select;

同时比较登录前后的 municipality_slug 请求参数和 Supabase 项目地址。若响应已有数据,就检查数据规范化和地图渲染;若响应为空,就沿查询条件与访问控制继续定位。SQL 编辑器能看到数据,不能单独证明浏览器角色也有相同可见性。

备注:内容仅供参考。