Menu

Next.js 项目 Supabase Auth 集成指南

基于 Supabase 官方 Next.js 认证指南 和 Nexty.dev 模板实际代码,本文档将详细介绍 Supabase Auth 在 Next.js 项目中的代码集成步骤和使用方式。

集成步骤详解

步骤 1:安装依赖包

npm install @supabase/supabase-js @supabase/ssr

依赖包说明:

  • @supabase/supabase-js - Supabase JavaScript 客户端库
  • @supabase/ssr - 专为服务端渲染优化的辅助库

步骤 2:环境变量配置

创建 .env.local 文件并配置必要的环境变量:

# Supabase 基础配置
NEXT_PUBLIC_SUPABASE_URL=your_supabase_project_url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
SUPABASE_SERVICE_ROLE_KEY=your_supabase_service_role_key
NEXT_PUBLIC_GOOGLE_CLIENT_ID=your_google_client_id

环境变量详细说明请查看:环境变量

步骤 3:创建 Supabase 客户端

3.1 浏览器客户端

lib/supabase/client.ts
import { Database } from '@/lib/supabase/types'
import { createBrowserClient } from '@supabase/ssr'
 
export const createClient = () => {
  return createBrowserClient<Database>(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
  )
}

这是专用于浏览器环境的客户端,用于:

  • 处理用户交互(登录、注册、数据更新)
  • 监听实时订阅数据变化
  • 提供认证状态管理。

3.2 服务端客户端

lib/supabase/server.ts
import { Database } from '@/lib/supabase/types'
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
 
export async function createClient() {
  const cookieStore = await cookies()
 
  return createServerClient<Database>(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll()
        },
        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options)
            )
          } catch {
            // Server Component 中调用 setAll 会被忽略
          }
        },
      },
    }
  )
}

这是专用于服务端组件的客户端,用于:

  • Server Actions 中的数据库操作
  • Route Handlers 中的 API 处理
  • 通过 Cookies 管理用户会话

步骤 4:设置中间件认证

我们需要先基于 Supabase 的功能完成中间件所需的权限控制逻辑。

lib/supabase/middleware.ts
/*
* 此处展示核心逻辑,非文件完整代码
*/
 
// 需要管理员权限的路由
const ROLE_PROTECTED_ROUTES: Record<string, string[]> = {
  'dashboard/users': ['admin'],
};
 
// 需要登录的路由
const PROTECTED_ROUTES = ['dashboard'];
 
export async function updateSession(request: NextRequest) {
  // 获取用户信息
  const { data: { user } } = await supabase.auth.getUser()
  
  // 检查登录状态
  if (!user && isProtectedRoute) {
    return NextResponse.redirect(new URL("/login", request.url))
  }
  
  // 检查角色权限
  if (user && needsRoleCheck) {
    const { data } = await supabase.from("users").select("role").eq("id", user.id).single();
    if (!allowedRoles.includes(data?.role || "user")) {
      return NextResponse.redirect(new URL("/403", request.url))
    }
  }
}

这个文件提供了实现认证状态管理逻辑:

  • 通过 updateSession() 检查和更新用户认证状态
  • 处理 token 刷新、会话验证等

还提供了权限控制的逻辑:

  • 管理员权限的页面禁止其他用户访问
  • 已登录普通用户的页面禁止未登录用户访问
  • 重定向到登录页设置回调地址,用户登录成功后可直接进入目标页面

lib/supabase/middleware.ts 引入 middleware.ts,使上述逻辑生效。

middleware.ts
import { updateSession } from '@/lib/supabase/middleware';
import createIntlMiddleware from 'next-intl/middleware';
import { NextRequest, NextResponse } from 'next/server';
import { routing } from './i18n/routing';
 
const intlMiddleware = createIntlMiddleware(routing);
 
export async function middleware(request: NextRequest): Promise<NextResponse> {
  // 1. 先处理 Supabase 认证
  const supabaseResponse = await updateSession(request);
 
  if (supabaseResponse.headers.get('location')) {
    return supabaseResponse;
  }
 
  // 2. 再处理国际化路由
  const intlResponse = intlMiddleware(request);
 
  // 3. 合并 Cookie
  supabaseResponse.cookies.getAll().forEach((cookie) => {
    const { name, value, ...options } = cookie;
    intlResponse.cookies.set(name, value, options);
  });
 
  return intlResponse;
}

这样我们就完成了中间件的完整需求:

  • 自动刷新过期的认证令牌
  • 权限检查,保护需要登录的路由
  • 角色验证,检查管理员权限
  • 国际化集成

步骤 5:前端认证状态管理

[locale]/layout.tsx 文件中,我们引入 <AuthProvider> 实现了前端全局认证状态管理。

5.1 AuthProvider 的核心功能

AuthProvider.tsx
type AuthContextType = {
  user: ExtendedUser | null;           // 带角色信息的用户对象
  loading: boolean;                    // 加载状态
  signInWithGoogle: () => Promise<{ error: AuthError | null }>;
  signInWithGithub: () => Promise<{ error: AuthError | null }>;
  signInWithEmail: (email: string, captchaToken?: string) => Promise<{ error: AuthError | null }>;
  signOut: () => Promise<void>;
  refreshUser: () => Promise<void>;    // 手动刷新用户信息
};

5.2 扩展用户信息

模板对 Supabase 的用户类型进行了扩展,添加了角色信息:

type ExtendedUser = User & {
  role: "admin" | "user";
};
 
const fetchUserRole = async (userId: string) => {
  const { data, error } = await supabase
    .from("users")
    .select("role")
    .eq("id", userId)
    .limit(1)
    .maybeSingle();
 
  return data?.role || "user";
};

如果你的前端还需要 User 表或其他数据表的重要用户数据,可以在 fetchUserRole 方法里扩展。

5.3 实时角色更新

模板实现了角色变化的实时监听:

const userSubscription = supabase
  .channel("public:users")
  .on(
    "postgres_changes",
    {
      event: "UPDATE",
      schema: "public",
      table: "users",
      filter: `id=eq.${user?.id}`,
    },
    async (payload) => {
      if (user) {
        setUser({
          ...user,
          role: payload.new.role,
        });
      }
    }
  )
  .subscribe();

5.4 多种登录方式支持

社交登录

const signInWithGoogle = async () => {
  return await supabase.auth.signInWithOAuth({
    provider: "google",
    options: {
      redirectTo: `${window.location.origin}/auth/callback?next=${next}`,
    },
  });
};
 
const signInWithGithub = async () => {
  return await supabase.auth.signInWithOAuth({
    provider: "github",
    options: {
      redirectTo: `${window.location.origin}/auth/callback?next=${next}`,
    },
  });
};

邮箱 Magic Link

const signInWithEmail = async (email: string, captchaToken?: string) => {
  return await supabase.auth.signInWithOtp({
    email: normalizeEmail(email),
    options: {
      emailRedirectTo: `${window.location.origin}/auth/callback?next=${next}`,
      captchaToken,
    },
  });
};

步骤 6:认证回调处理

模板实现了完整的认证回调处理机制。

6.1 OAuth 回调

app/auth/callback/route.ts
export async function GET(request: Request) {
  const { searchParams, origin } = new URL(request.url)
  const code = searchParams.get('code')
  let next = searchParams.get('next') ?? '/'
 
  if (code) {
    const supabase = await createClient()
    const { error } = await supabase.auth.exchangeCodeForSession(code)
    if (!error) {
      return NextResponse.redirect(`${origin}${next}`)
    }
  }
 
  return NextResponse.redirect(new URL(`/redirect-error?code=server_error`, origin))
}

6.2 邮箱确认

app/auth/confirm/route.ts
export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url)
  const token_hash = searchParams.get('token_hash')
  const type = searchParams.get('type') as EmailOtpType | null
 
  if (!token_hash || !type) {
    return NextResponse.redirect(new URL(`/redirect-error?code=invalid_params`, origin))
  }
 
  const supabase = await createClient()
  const { error } = await supabase.auth.verifyOtp({
    type,
    token_hash,
  })
 
  if (error) {
    // 详细的错误处理
    switch (error.message) {
      case 'Token has expired':
        return NextResponse.redirect(new URL(`/redirect-error?code=token_expired`, origin))
      // ... 其他错误处理
    }
  }
 
  return NextResponse.redirect(new URL(next, origin))
}

实际项目中的应用

在客户端组件中使用

import { useAuth } from '@/components/providers/AuthProvider';
 
export default function UserProfile() {
  const { user, loading, signOut } = useAuth();
 
  if (loading) return <div>Loading...</div>;
  
  if (!user) {
    return <LoginButton />;
  }
 
  return (
    <div>
      <h1>Welcome, {user.email}</h1>
      <p>Role: {user.role}</p>
      {user.role === 'admin' && <AdminPanel />}
      <button onClick={signOut}>Sign Out</button>
    </div>
  );
}

在服务端组件中使用

import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';
 
export default async function PrivatePage() {
  const supabase = await createClient();
  const { data: { user }, error } = await supabase.auth.getUser();
 
  if (error || !user) {
    redirect('/login');
  }
 
  return <p>Hello {user.email}</p>;
}

在 Server Actions 中使用

'use server'
import { createClient as createAdminClient } from "@supabase/supabase-js";
import { isAdmin } from '@/lib/supabase/isAdmin';
 
export async function adminAction() {
  if (!(await isAdmin())) {
    return actionResponse.forbidden("Admin privileges required.");
  }
 
  const supabaseAdmin = createAdminClient<Database>(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.SUPABASE_SERVICE_ROLE_KEY!
  );
  // 执行管理员操作
}

安全最佳实践

1. 服务端使用 getUser() 而非 getSession()

// ✅ 正确 - 服务端总是验证令牌
const { data: { user } } = await supabase.auth.getUser();
 
// ❌ 错误 - 服务端不应使用,令牌可能未验证
const { data: { session } } = await supabase.auth.getSession();

2. 分离客户端和服务端逻辑

// 客户端组件 - 使用浏览器客户端
const supabase = createClient(); // from @/lib/supabase/client
 
// 服务端组件/Actions - 使用服务端客户端  
const supabase = await createClient(); // from @/lib/supabase/server

3. 敏感操作鉴权后使用 Service Role Key

涉及敏感操作,需要先判断是管理员身份,然后使用 Service Role Key 操作数据库

import { createClient as createAdminClient } from "@supabase/supabase-js";
import { isAdmin } from '@/lib/supabase/isAdmin';
 
// 判断管理员身份
if (!(await isAdmin())) {
  return actionResponse.forbidden("Admin privileges required.");
}
 
// 管理员使用 Service Role Key 操作数据库
const supabaseAdmin = createAdminClient<Database>(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY! // 仅服务端使用
);