使用 Next.js 构建用户管理应用
本教程展示如何构建一个基本的用户管理应用程序。该应用程序对用户进行身份验证和识别,将其个人资料信息存储在数据库中,并允许用户登录、更新个人资料详细信息以及上传个人资料照片。该应用程序使用:
- Supabase数据库 - 一个Postgres数据库,用于存储用户数据,并通过行级安全性来保护数据,确保用户只能访问自己的信息。
- Supabase身份验证 - 允许用户注册和登录。
- Supabase存储 - 用户可以上传个人资料照片。
如果在学习本指南时遇到问题,请参考 GitHub上的完整示例。
项目设置
在开始构建之前,我们需要设置数据库和 API。这就像在 Supabase 中创建一个新项目,然后在数据库中创建一个 “模式” 一样简单。
创建项目
- 在 Supabase 仪表板中创建一个新项目。
- 输入项目详细信息。
- 等待新数据库启动。
设置数据库模式
现在我们要设置数据库模式。我们可以在 SQL 编辑器中使用 “用户管理入门” 快速启动模板,或者您也可以直接复制/粘贴下面的 SQL 并自行运行。
获取 API 密钥
既然您已经创建了一些数据库表,就可以使用自动生成的 API 插入数据了。
我们只需要从 API 设置中获取项目 URL 和 anon
密钥。
- 前往仪表板中的 API 设置 页面。
- 在该页面找到您的项目
URL
、anon
和service_role
密钥。
构建应用
让我们从零开始构建 Next.js 应用。
初始化 Next.js 应用
我们可以使用 create-next-app
来初始化一个名为 supabase-nextjs
的应用:
12npx create-next-app@latest --use-npm supabase-nextjscd supabase-nextjs
然后安装 Supabase 客户端库:supabase-js
1npm install @supabase/supabase-js
最后我们需要将环境变量保存在 .env.local
文件中。
在项目根目录创建 .env.local
文件,并粘贴您之前获取的 API URL 和 anon
密钥。
12NEXT_PUBLIC_SUPABASE_URL=YOUR_SUPABASE_URLNEXT_PUBLIC_SUPABASE_ANON_KEY=YOUR_SUPABASE_ANON_KEY
应用样式(可选)
可选步骤是更新 CSS 文件 app/globals.css
来美化应用外观。
您可以在此处找到该文件的完整内容这里。
Supabase 服务端认证
Next.js 是一个高度灵活的框架,提供了构建时预渲染(SSG)、请求时服务端渲染(SSR)、API路由和中间件边缘函数等功能。
为了更好地与框架集成,我们创建了@supabase/ssr
包用于服务端认证。它包含所有必要的功能,可以快速配置您的Supabase项目使用cookie存储用户会话。更多信息请参阅Next.js服务端认证指南。
为Next.js安装该包:
1npm install @supabase/ssr
Supabase 工具集
Supabase 中有两种不同类型的客户端:
- 客户端组件客户端 - 用于在浏览器中运行的客户端组件访问 Supabase
- 服务端组件客户端 - 用于在服务端运行的服务器组件、服务器操作和路由处理程序访问 Supabase
建议创建以下核心工具文件来创建客户端,并将其组织在项目根目录的 utils/supabase
文件夹中。
分别创建 client.js
和 server.js
来实现客户端和服务端的 Supabase 功能。
123456789import { createBrowserClient } from '@supabase/ssr'export function createClient() { // 使用项目凭证在浏览器中创建 supabase 客户端 return createBrowserClient( process.env.NEXT_PUBLIC_SUPABASE_URL, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY )}
Next.js 中间件
由于服务器组件(Server Components)无法写入cookies,您需要使用中间件来刷新过期的Auth令牌并存储它们。具体实现方式如下:
- 通过调用
supabase.auth.getUser
来刷新Auth令牌 - 通过
request.cookies.set
将刷新后的Auth令牌传递给服务器组件,避免它们重复刷新同一个令牌 - 通过
response.cookies.set
将刷新后的Auth令牌传递给浏览器,替换旧令牌
您还可以添加匹配器(matcher),使中间件仅在对Supabase进行访问的路由上运行。更多信息请参阅此文档。
保护页面时需格外小心。服务器从cookies中获取用户会话,这些cookies可能被任何人伪造。
始终使用supabase.auth.getUser()
来保护页面和用户数据。
在中间件等服务器代码中,_永远不要_信任supabase.auth.getSession()
。它不能保证会重新验证Auth令牌。
可以安全信任getUser()
,因为它每次都会向Supabase Auth服务器发送请求来重新验证Auth令牌。
在项目根目录创建middleware.js
文件,并在utils/supabase
文件夹内创建另一个文件。utils/supabase
文件包含更新会话的逻辑,由符合Next.js约定的middleware.js
文件使用。
12345678910111213141516171819import { updateSession } from '@/utils/supabase/middleware'export async function middleware(request) { // 更新用户的auth会话 return await updateSession(request)}export const config = { matcher: [ /* * 匹配所有请求路径,除了以下开头的路径: * - _next/static (静态文件) * - _next/image (图片优化文件) * - favicon.ico (网站图标文件) * 可根据需要修改此模式以包含更多路径。 */ '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)', ],}
设置登录页面
登录与注册表单
为您的应用创建登录/注册页面:
新建名为 login
的文件夹,其中包含带有登录/注册表单的 page.jsx
文件。
1234567891011121314import { login, signup } from './actions'export default function LoginPage() { return ( <form> <label htmlFor="email">邮箱:</label> <input id="email" name="email" type="email" required /> <label htmlFor="password">密码:</label> <input id="password" name="password" type="password" required /> <button formAction={login}>登录</button> <button formAction={signup}>注册</button> </form> )}
访问 http://localhost:3000/login
,您将看到登录表单,但尚未与实际登录功能关联。接下来需要创建登录/注册操作,这些操作将:
- 获取用户信息
- 将信息作为注册请求发送至 Supabase,Supabase 会发送确认邮件
- 处理可能出现的错误
注意:在调用 Supabase 之前会先调用 cookies,这将使 fetch 调用退出 Next.js 的缓存机制。这对于认证数据获取非常重要,能确保用户只能访问自己的数据。
参阅 Next.js 文档了解更多关于退出数据缓存的内容。
1234567891011121314151617181920212223242526272829303132333435363738394041424344'use server'import { revalidatePath } from 'next/cache'import { redirect } from 'next/navigation'import { createClient } from '@/utils/supabase/server'export async function login(formData) { const supabase = await createClient() // 此处为方便进行类型转换 // 实际应用中应验证输入内容 const data = { email: formData.get('email'), password: formData.get('password'), } const { error } = await supabase.auth.signInWithPassword(data) if (error) { redirect('/error') } revalidatePath('/', 'layout') redirect('/account')}export async function signup(formData) { const supabase = await createClient() const data = { email: formData.get('email'), password: formData.get('password'), } const { error } = await supabase.auth.signUp(data) if (error) { redirect('/error') } revalidatePath('/', 'layout') redirect('/account')}
当您输入邮箱和密码后,将会收到标题为确认您的注册的邮件。恭喜 🎉!!!
邮件模板
修改邮件模板以支持服务端认证流程。
在继续之前,我们需要更改邮件模板以支持发送令牌哈希:
- 进入仪表板的认证模板页面
- 选择
确认注册
模板 - 将
{{ .ConfirmationURL }}
修改为{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email
您知道吗?您还可以自定义发送给新用户的邮件,包括邮件外观、内容和查询参数。查看您项目的设置。
确认端点
由于我们工作在服务器端渲染(SSR)环境中,有必要创建一个负责用 token_hash
交换会话的服务器端点。
在以下代码片段中,我们执行以下步骤:
- 使用
token_hash
查询参数从 Supabase 认证服务器检索返回的代码 - 将此代码交换为会话,并将其存储在我们选择的存储机制中(本例中为 cookies)
- 最后将用户重定向到
account
页面
12345678910111213141516171819202122232425262728293031323334import { NextResponse } from 'next/server'import { createClient } from '@/utils/supabase/server'// 创建处理 /auth/confirm 路由 GET 请求的处理器export async function GET(request) { const { searchParams } = new URL(request.url) const token_hash = searchParams.get('token_hash') const type = searchParams.get('type') const next = '/account' // 创建不包含密钥令牌的重定向链接 const redirectTo = request.nextUrl.clone() redirectTo.pathname = next redirectTo.searchParams.delete('token_hash') redirectTo.searchParams.delete('type') if (token_hash && type) { const supabase = await createClient() const { error } = await supabase.auth.verifyOtp({ type, token_hash, }) if (!error) { redirectTo.searchParams.delete('next') return NextResponse.redirect(redirectTo) } } // 将用户重定向到包含说明的错误页面 redirectTo.pathname = '/error' return NextResponse.redirect(redirectTo)}
账户页面
当用户登录后,我们可以允许他们编辑个人资料信息和管理账户。
让我们在 app/account
文件夹中创建一个名为 AccountForm
的新组件。
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118'use client'import { useCallback, useEffect, useState } from 'react'import { createClient } from '@/utils/supabase/client'export default function AccountForm({ user }) { const supabase = createClient() const [loading, setLoading] = useState(true) const [fullname, setFullname] = useState(null) const [username, setUsername] = useState(null) const [website, setWebsite] = useState(null) const [avatar_url, setAvatarUrl] = useState(null) const getProfile = useCallback(async () => { try { setLoading(true) const { data, error, status } = await supabase .from('profiles') .select(`full_name, username, website, avatar_url`) .eq('id', user?.id) .single() if (error && status !== 406) { throw error } if (data) { setFullname(data.full_name) setUsername(data.username) setWebsite(data.website) setAvatarUrl(data.avatar_url) } } catch (error) { alert('加载用户数据出错!') } finally { setLoading(false) } }, [user, supabase]) useEffect(() => { getProfile() }, [user, getProfile]) async function updateProfile({ username, website, avatar_url }) { try { setLoading(true) const { error } = await supabase.from('profiles').upsert({ id: user?.id, full_name: fullname, username, website, avatar_url, updated_at: new Date().toISOString(), }) if (error) throw error alert('资料已更新!') } catch (error) { alert('更新数据出错!') } finally { setLoading(false) } } return ( <div className="form-widget"> <div> <label htmlFor="email">邮箱</label> <input id="email" type="text" value={user?.email} disabled /> </div> <div> <label htmlFor="fullName">全名</label> <input id="fullName" type="text" value={fullname || ''} onChange={(e) => setFullname(e.target.value)} /> </div> <div> <label htmlFor="username">用户名</label> <input id="username" type="text" value={username || ''} onChange={(e) => setUsername(e.target.value)} /> </div> <div> <label htmlFor="website">网站</label> <input id="website" type="url" value={website || ''} onChange={(e) => setWebsite(e.target.value)} /> </div> <div> <button className="button primary block" onClick={() => updateProfile({ fullname, username, website, avatar_url })} disabled={loading} > {loading ? '加载中...' : '更新'} </button> </div> <div> <form action="/auth/signout" method="post"> <button className="button block" type="submit"> 退出登录 </button> </form> </div> </div> )}
为我们刚刚创建的 AccountForm
组件创建一个账户页面
123456789101112import AccountForm from './account-form'import { createClient } from '@/utils/supabase/server'export default async function Account() { const supabase = await createClient() const { data: { user }, } = await supabase.auth.getUser() return <AccountForm user={user} />}
退出登录
让我们创建一个路由处理器来处理服务器端的退出登录操作。首先确保检查用户是否已登录!
123456789101112131415161718192021import { createClient } from '@/utils/supabase/server'import { revalidatePath } from 'next/cache'import { NextResponse } from 'next/server'export async function POST(req) { const supabase = await createClient() // 检查用户是否已登录 const { data: { user }, } = await supabase.auth.getUser() if (user) { await supabase.auth.signOut() } revalidatePath('/', 'layout') return NextResponse.redirect(new URL('/login', req.url), { status: 302, })}
启动应用!
现在我们已经准备好了所有页面、路由处理器和组件,在终端窗口中运行以下命令:
1npm run dev
然后在浏览器中打开 localhost:3000,您应该能看到完整的应用程序。
额外功能:个人头像
每个 Supabase 项目都配置了存储功能,用于管理照片和视频等大型文件。
创建上传组件
让我们为用户创建一个头像上传组件,使他们能够上传个人资料照片。我们可以从创建一个新组件开始:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687'use client'import React, { useEffect, useState } from 'react'import { createClient } from '@/utils/supabase/client'import Image from 'next/image'export default function Avatar({ uid, url, size, onUpload }) { const supabase = createClient() const [avatarUrl, setAvatarUrl] = useState(url) const [uploading, setUploading] = useState(false) useEffect(() => { async function downloadImage(path) { try { const { data, error } = await supabase.storage.from('avatars').download(path) if (error) { throw error } const url = URL.createObjectURL(data) setAvatarUrl(url) } catch (error) { console.log('下载图片错误: ', error) } } if (url) downloadImage(url) }, [url, supabase]) const uploadAvatar = async (event) => { try { setUploading(true) if (!event.target.files || event.target.files.length === 0) { throw new Error('必须选择要上传的图片') } const file = event.target.files[0] const fileExt = file.name.split('.').pop() const filePath = `${uid}-${Math.random()}.${fileExt}` const { error: uploadError } = await supabase.storage.from('avatars').upload(filePath, file) if (uploadError) { throw uploadError } onUpload(filePath) } catch (error) { alert('上传头像出错!') } finally { setUploading(false) } } return ( <div> {avatarUrl ? ( <Image width={size} height={size} src={avatarUrl} alt="头像" className="avatar image" style={{ height: size, width: size }} /> ) : ( <div className="avatar no-image" style={{ height: size, width: size }} /> )} <div style={{ width: size }}> <label className="button primary block" htmlFor="single"> {uploading ? '上传中...' : '上传'} </label> <input style={{ visibility: 'hidden', position: 'absolute', }} type="file" id="single" accept="image/*" onChange={uploadAvatar} disabled={uploading} /> </div> </div> )}
添加新组件
然后我们可以将组件添加到 AccountForm
中:
1234567891011121314151617181920// 导入新组件import Avatar from './avatar'// ...return ( <div className="form-widget"> {/* 添加到主体 */} <Avatar uid={user?.id} url={avatar_url} size={150} onUpload={(url) => { setAvatarUrl(url) updateProfile({ fullname, username, website, avatar_url: url }) }} /> {/* ... */} </div>)
至此,您已经拥有一个功能完整的应用!
相关资源
- 查看完整的 GitHub 示例 并部署到 Vercel
- 使用 Next.js App Router 和 Supabase 构建 Twitter 克隆 - egghead 免费课程
- 探索 React 预构建的 Auth UI
- 探索 Next.js 的 Auth 辅助工具
- 探索 Supabase 缓存辅助工具
- 查看 Next.js 订阅支付入门模板