使用 Next.js 构建用户管理应用
探索适用于你的 Supabase 应用的即用型 UI 组件。
基于 shadcn/ui 构建的 UI 组件,通过单个命令连接到 Supabase。
探索组件本教程演示如何构建一个基本的用户管理应用。该应用对用户进行身份验证和识别,将他们的个人资料信息存储在数据库中,并允许用户登录、更新他们的个人资料详细信息以及上传个人资料照片。该应用使用
- Supabase 数据库 - 一个用于存储用户数据的 Postgres 数据库,以及 行级别安全,以保护数据并确保用户只能访问他们自己的信息。
- Supabase Auth - 允许用户注册和登录。
- Supabase Storage - 允许用户上传个人资料照片。

如果在阅读本指南时遇到问题,请参考 GitHub 上的完整示例。
项目设置#
在开始构建之前,您需要设置数据库和 API。您可以通过在 Supabase 中启动一个新项目,然后在数据库中创建一个“schema”来完成此操作。
创建项目#
- 在 Supabase 控制面板中创建一个新项目。
- 输入您的项目详细信息。
- 等待新的数据库启动。
设置数据库 schema#
现在设置数据库 schema。您可以使用 SQL 编辑器中的“用户管理 Starter”快速入门,或者您可以复制/粘贴下面的 SQL 并运行它。
获取 API 详细信息#
现在您已经创建了一些数据库表,您可以使用自动生成的 API 插入数据。
为此,你需要从 项目 Connect 对话框 获取项目 URL 和密钥。
API 密钥的更改
Supabase 正在更改密钥的工作方式,以提高项目安全性和开发人员体验。您可以 阅读完整的公告,但在过渡期间,您可以使用当前的 anon 和 service_role 密钥以及新的可发布密钥,格式为 sb_publishable_xxx,它将取代旧的密钥。
在大多数情况下,你可以从 项目的 连接 对话框 获取正确的密钥,但如果你想要特定的密钥,你可以在 项目的设置页面中的 API 密钥部分 找到所有密钥
- 对于旧版密钥,从 旧版 API 密钥 选项卡中复制
anon密钥用于客户端操作,并复制service_role密钥用于服务器端操作。 - 对于新密钥,打开 API 密钥 选项卡,如果您还没有可发布密钥,请单击 创建新的 API 密钥,并复制 可发布密钥 部分中的值。
阅读 API 密钥文档 以全面了解所有密钥类型及其用途。
构建应用#
从头开始构建 Next.js 应用。
初始化 Next.js 应用#
使用 create-next-app 初始化一个名为 supabase-nextjs 的应用
1npx create-next-app@latest --ts --use-npm supabase-nextjs2cd supabase-nextjs然后安装 Supabase 客户端库:supabase-js
1npm install @supabase/supabase-js将环境变量保存在项目根目录下的 .env.local 文件中,并粘贴你之前复制的 API URL 和密钥 此处。
1NEXT_PUBLIC_SUPABASE_URL=YOUR_SUPABASE_URL2NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=YOUR_SUPABASE_PUBLISHABLE_KEY应用样式 (可选)#
可选步骤是更新 CSS 文件 app/globals.css 以使应用看起来更美观。你可以在 示例仓库 中找到此文件的完整内容。
Supabase 服务器端认证#
Next.js 是一个高度通用的框架,提供构建时预渲染 (SSG)、请求时服务器端渲染 (SSR)、API 路由和代理边缘函数。
为了更好地与框架集成,我们创建了 @supabase/ssr 包用于服务器端认证。它具有所有功能,可以快速配置你的 Supabase 项目以使用 cookie 存储用户会话。阅读 Next.js 服务器端认证指南 以获取更多信息。
安装 Next.js 的包。
1npm install @supabase/ssrSupabase 工具#
Supabase 中有两种不同类型的客户端
- 客户端组件客户端 - 要从在浏览器中运行的客户端组件访问 Supabase。
- 服务器组件客户端 - 要从仅在服务器上运行的服务器组件、服务器操作和路由处理程序访问 Supabase。
建议创建以下基本工具文件来创建客户端,并在项目根目录下的 lib/supabase 文件夹中组织它们。
创建带有以下功能的 client.ts 和 server.ts 文件,分别用于客户端 Supabase 和服务器端 Supabase。
1import { createBrowserClient } from "@supabase/ssr";23export function createClient() {4 // Create a supabase client on the browser with project's credentials5 return createBrowserClient(6 process.env.NEXT_PUBLIC_SUPABASE_URL!,7 process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!8 );9}Next.js 代理#
由于服务器组件无法写入 cookie,因此你需要 Proxy 来刷新过期的认证令牌并存储它们。这是通过以下方式实现的
- 使用对
supabase.auth.getUser的调用刷新认证令牌。 - 通过
request.cookies.set将刷新的认证令牌传递给服务器组件,这样它们就不会尝试刷新相同的令牌。 - 使用
response.cookies.set将刷新的认证令牌传递给浏览器,以便它替换旧令牌。
你还可以添加一个匹配器,以便 Proxy 仅在访问 Supabase 的路由上运行。有关更多信息,请阅读 Next.js 匹配器文档。
在保护页面时要小心。服务器从 cookie 中获取用户会话,而任何人都可以伪造 cookie。
始终使用 supabase.auth.getUser() 来保护页面和用户数据。
切勿 信任服务器代码(例如代理)中的 supabase.auth.getSession()。不能保证它会重新验证认证令牌。
可以信任 getUser(),因为它每次都会向 Supabase 认证服务器发送请求以重新验证认证令牌。
在项目根目录中创建一个 proxy.ts 文件,并在 lib/supabase 文件夹中再创建一个。lib/supabase 文件包含更新会话的逻辑。这由 proxy.ts 文件使用,该文件是 Next.js 约定。
1import { type NextRequest } from 'next/server'2import { updateSession } from '@/lib/supabase/proxy'34export async function proxy(request: NextRequest) {5 // update user's auth session6 return await updateSession(request)7}89export const config = {10 matcher: [11 /*12 * Match all request paths except for the ones starting with:13 * - _next/static (static files)14 * - _next/image (image optimization files)15 * - favicon.ico (favicon file)16 * Feel free to modify this pattern to include more paths.17 */18 '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',19 ],20}设置登录页面#
登录和注册表单#
为了为你的应用程序添加登录/注册页面
创建一个名为 login 的新文件夹,其中包含一个带有登录/注册表单的 page.tsx 文件。
1import { login, signup } from './actions'23export default function LoginPage() {4 return (5 <form>6 <label htmlFor="email">Email:</label>7 <input id="email" name="email" type="email" required />8 <label htmlFor="password">Password:</label>9 <input id="password" name="password" type="password" required />10 <button formAction={login}>Log in</button>11 <button formAction={signup}>Sign up</button>12 </form>13 )14}接下来,你需要创建登录/注册操作来连接表单到函数。它执行以下操作
- 检索用户的信息。
- 将该信息发送到 Supabase 作为注册请求,这反过来会发送一封确认电子邮件。
- 处理任何出现的错误。
在调用 Supabase 之前调用 cookies 方法,这会将提取调用从 Next.js 的缓存中排除。这对于经过身份验证的数据提取非常重要,以确保用户只能访问他们自己的数据。
阅读 Next.js 文档以了解更多关于 选择退出数据缓存 的信息。
在 app/login 文件夹中创建 action.ts 文件,其中包含登录和注册函数以及 error/page.tsx 文件,如果登录或注册失败,则显示错误消息。
1'use server'23import { revalidatePath } from 'next/cache'4import { redirect } from 'next/navigation'56import { createClient } from '@/lib/supabase/server'78export async function login(formData: FormData) {9 const supabase = await createClient()1011 // type-casting here for convenience12 // in practice, you should validate your inputs13 const data = {14 email: formData.get('email') as string,15 password: formData.get('password') as string,16 }1718 const { error } = await supabase.auth.signInWithPassword(data)1920 if (error) {21 redirect('/error')22 }2324 revalidatePath('/', 'layout')25 redirect('/account')26}2728export async function signup(formData: FormData) {29 const supabase = await createClient()3031 // type-casting here for convenience32 // in practice, you should validate your inputs33 const data = {34 email: formData.get('email') as string,35 password: formData.get('password') as string,36 }3738 const { error } = await supabase.auth.signUp(data)3940 if (error) {41 redirect('/error')42 }4344 revalidatePath('/', 'layout')45 redirect('/account')46}电子邮件模板#
在继续操作之前,更改电子邮件模板以支持支持服务器端身份验证流程,该流程发送一个令牌哈希
- 转到仪表板中的 Auth 模板 页面。
- 选择 Confirm signup 模板。
- 将
{{ .ConfirmationURL }}更改为{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email。
你知道吗? 你还可以自定义发送给新用户的其他电子邮件,包括电子邮件的外观、内容和查询参数。请查看 你的项目设置。
确认端点#
由于你正在使用服务器端渲染 (SSR) 环境工作,因此你需要创建一个服务器端点来负责用会话交换 token_hash。
代码执行以下步骤
- 使用
token_hash查询参数从 Supabase Auth 服务器检索发送回来的代码。 - 将此代码交换为会话,你将其存储在所选存储机制中(在本例中为 cookie)。
- 最后,将用户重定向到
account页面。
app/auth/confirm/route.ts
1import { type EmailOtpType } from '@supabase/supabase-js'2import { type NextRequest, NextResponse } from 'next/server'3import { createClient } from '@/lib/supabase/server'45// Creating a handler to a GET request to route /auth/confirm6export async function GET(request: NextRequest) {7 const { searchParams } = new URL(request.url)8 const token_hash = searchParams.get('token_hash')9 const type = searchParams.get('type') as EmailOtpType | null10 const next = '/account'1112 // Create redirect link without the secret token13 const redirectTo = request.nextUrl.clone()14 redirectTo.pathname = next15 redirectTo.searchParams.delete('token_hash')16 redirectTo.searchParams.delete('type')1718 if (token_hash && type) {19 const supabase = await createClient()2021 const { error } = await supabase.auth.verifyOtp({22 type,23 token_hash,24 })25 if (!error) {26 redirectTo.searchParams.delete('next')27 return NextResponse.redirect(redirectTo)28 }29 }3031 // return the user to an error page with some instructions32 redirectTo.pathname = '/error'33 return NextResponse.redirect(redirectTo)34}账户页面#
用户登录后,允许他们编辑他们的个人资料详细信息并管理他们的帐户。
在 app/account 文件夹中创建一个名为 AccountForm 的新组件。
app/account/account-form.tsx
1'use client'2import { useCallback, useEffect, useState } from 'react'3import { createClient } from '@/lib/supabase/client'4import { type User } from '@supabase/supabase-js'56// ...78export default function AccountForm({ user }: { user: User | null }) {9 const supabase = createClient()10 const [loading, setLoading] = useState(true)11 const [fullname, setFullname] = useState<string | null>(null)12 const [username, setUsername] = useState<string | null>(null)13 const [website, setWebsite] = useState<string | null>(null)14 const [avatar_url, setAvatarUrl] = useState<string | null>(null)1516 const getProfile = useCallback(async () => {17 try {18 setLoading(true)1920 const { data, error, status } = await supabase21 .from('profiles')22 .select(`full_name, username, website, avatar_url`)23 .eq('id', user?.id)24 .single()2526 if (error && status !== 406) {27 console.log(error)28 throw error29 }3031 if (data) {32 setFullname(data.full_name)33 setUsername(data.username)34 setWebsite(data.website)35 setAvatarUrl(data.avatar_url)36 }37 } catch (error) {38 alert('Error loading user data!')39 } finally {40 setLoading(false)41 }42 }, [user, supabase])4344 useEffect(() => {45 getProfile()46 }, [user, getProfile])4748 async function updateProfile({49 username,50 website,51 avatar_url,52 }: {53 username: string | null54 fullname: string | null55 website: string | null56 avatar_url: string | null57 }) {58 try {59 setLoading(true)6061 const { error } = await supabase.from('profiles').upsert({62 id: user?.id as string,63 full_name: fullname,64 username,65 website,66 avatar_url,67 updated_at: new Date().toISOString(),68 })69 if (error) throw error70 alert('Profile updated!')71 } catch (error) {72 alert('Error updating the data!')73 } finally {74 setLoading(false)75 }76 }7778 return (79 <div className="form-widget">8081 {/* ... */}8283 <div>84 <label htmlFor="email">Email</label>85 <input id="email" type="text" value={user?.email} disabled />86 </div>87 <div>88 <label htmlFor="fullName">Full Name</label>89 <input90 id="fullName"91 type="text"92 value={fullname || ''}93 onChange={(e) => setFullname(e.target.value)}94 />95 </div>96 <div>97 <label htmlFor="username">Username</label>98 <input99 id="username"100 type="text"101 value={username || ''}102 onChange={(e) => setUsername(e.target.value)}103 />104 </div>105 <div>106 <label htmlFor="website">Website</label>107 <input108 id="website"109 type="url"110 value={website || ''}111 onChange={(e) => setWebsite(e.target.value)}112 />113 </div>114115 <div>116 <button117 className="button primary block"118 onClick={() => updateProfile({ fullname, username, website, avatar_url })}119 disabled={loading}120 >121 {loading ? 'Loading ...' : 'Update'}122 </button>123 </div>124125 <div>126 <form action="/auth/signout" method="post">127 <button className="button block" type="submit">128 Sign out129 </button>130 </form>131 </div>132 </div>133 )134}为刚刚创建的 AccountForm 组件创建一个帐户页面
app/account/page.tsx
1import AccountForm from './account-form'2import { createClient } from '@/lib/supabase/server'34export default async function Account() {5 const supabase = await createClient()67 const {8 data: { user },9 } = await supabase.auth.getUser()1011 return <AccountForm user={user} />12}注销#
创建一个路由处理程序来处理服务器端的注销,确保首先检查用户是否已登录。
app/auth/signout/route.ts
1import { createClient } from "@/lib/supabase/server";2import { revalidatePath } from "next/cache";3import { type NextRequest, NextResponse } from "next/server";45export async function POST(req: NextRequest) {6 const supabase = await createClient();78 // Check if a user's logged in9 const {10 data: { user },11 } = await supabase.auth.getUser();1213 if (user) {14 await supabase.auth.signOut();15 }1617 revalidatePath("/", "layout");18 return NextResponse.redirect(new URL("/login", req.url), {19 status: 302,20 });21}启动#
现在你已经准备好所有页面、路由处理程序和组件,在终端窗口中运行以下命令
1npm run dev然后打开浏览器到 localhost:3000/login,你应该看到完成的应用程序。
当你输入你的电子邮件和密码时,你将收到一封标题为 Confirm Your Signup 的电子邮件。恭喜 🎉!!!
奖励:个人资料照片#
每个 Supabase 项目都配置了 存储,用于管理大型文件,如照片和视频。
创建一个上传小部件#
为用户创建一个头像小部件,以便他们可以上传个人资料照片。首先创建一个新组件
app/account/avatar.tsx
1'use client'2import React, { useEffect, useState } from 'react'3import { createClient } from '@/lib/supabase/client'4import Image from 'next/image'56export default function Avatar({7 uid,8 url,9 size,10 onUpload,11}: {12 uid: string | null13 url: string | null14 size: number15 onUpload: (url: string) => void16}) {17 const supabase = createClient()18 const [avatarUrl, setAvatarUrl] = useState<string | null>(url)19 const [uploading, setUploading] = useState(false)2021 useEffect(() => {22 async function downloadImage(path: string) {23 try {24 const { data, error } = await supabase.storage.from('avatars').download(path)25 if (error) {26 throw error27 }2829 const url = URL.createObjectURL(data)30 setAvatarUrl(url)31 } catch (error) {32 console.log('Error downloading image: ', error)33 }34 }3536 if (url) downloadImage(url)37 }, [url, supabase])3839 const uploadAvatar: React.ChangeEventHandler<HTMLInputElement> = async (event) => {40 try {41 setUploading(true)4243 if (!event.target.files || event.target.files.length === 0) {44 throw new Error('You must select an image to upload.')45 }4647 const file = event.target.files[0]48 const fileExt = file.name.split('.').pop()49 const filePath = `${uid}-${Math.random()}.${fileExt}`5051 const { error: uploadError } = await supabase.storage.from('avatars').upload(filePath, file)5253 if (uploadError) {54 throw uploadError55 }5657 onUpload(filePath)58 } catch (error) {59 alert('Error uploading avatar!')60 } finally {61 setUploading(false)62 }63 }6465 return (66 <div>67 {avatarUrl ? (68 <Image69 width={size}70 height={size}71 src={avatarUrl}72 alt="Avatar"73 className="avatar image"74 style={{ height: size, width: size }}75 />76 ) : (77 <div className="avatar no-image" style={{ height: size, width: size }} />78 )}79 <div style={{ width: size }}>80 <label className="button primary block" htmlFor="single">81 {uploading ? 'Uploading ...' : 'Upload'}82 </label>83 <input84 style={{85 visibility: 'hidden',86 position: 'absolute',87 }}88 type="file"89 id="single"90 accept="image/*"91 onChange={uploadAvatar}92 disabled={uploading}93 />94 </div>95 </div>96 )97}添加新的小部件#
然后将小部件添加到 AccountForm 组件
app/account/account-form.tsx
1// ...23import Avatar from './avatar'45 // ...67 return (8 <div className="form-widget">9 <Avatar10 uid={user?.id ?? null}11 url={avatar_url}12 size={150}13 onUpload={(url) => {14 setAvatarUrl(url)15 updateProfile({ fullname, username, website, avatar_url: url })16 }}17 />1819 {/* ... */}2021 </div>22 )23}此时,您已经拥有一个功能齐全的应用!
另请参阅#
- 查看完整的 GitHub 上的示例 并将其部署到 Vercel
- 使用 Next.js App Router 和 Supabase 构建 Twitter 克隆 - 免费 egghead 课程
- 探索 预构建的 Auth 组件
- 探索 Supabase Cache Helpers
- 查看 GitHub 上的 Next.js Subscription Payments Starter 模板