You need to enable JavaScript to run this app.
最新活动
大模型
产品
解决方案
定价
生态与合作
支持与服务
开发者
了解我们

Next.js 15 App Router + @supabase/ssr 魔法链接登录后地图标记消失的问题排查与最佳实践咨询

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_SESSIONSIGNED_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);
}

最终最佳实践总结

  1. 拒绝模块级Supabase单例:客户端用useMemo包裹的Hook创建实例,服务器端/路由用createSupabaseServerClient按需创建
  2. 必须添加middleware.ts:它是维护会话同步的核心,确保客户端与服务器端会话状态一致
  3. 优化状态变更依赖:避免竞态条件,确保数据Fetch只在会话状态稳定后执行
  4. 用官方方法处理认证回调:减少自定义逻辑的出错风险

你可以按照这个顺序逐步调整代码,应该就能解决登录后报告消失的问题。如果还有疑问,可以在浏览器控制台执行supabase.auth.getSession(),检查登录后是否能拿到有效会话,这是快速排查的关键。

火山引擎 最新活动