入门

使用 Next.js 构建用户管理应用


本教程演示如何构建一个基本的用户管理应用。该应用对用户进行身份验证和识别,将他们的个人资料信息存储在数据库中,并允许用户登录、更新他们的个人资料详细信息以及上传个人资料照片。该应用使用

Supabase User Management example

项目设置#

在开始构建之前,您需要设置数据库和 API。您可以通过在 Supabase 中启动一个新项目,然后在数据库中创建一个“schema”来完成此操作。

创建项目#

  1. 在 Supabase 控制面板中创建一个新项目
  2. 输入您的项目详细信息。
  3. 等待新的数据库启动。

设置数据库 schema#

现在设置数据库 schema。您可以使用 SQL 编辑器中的“用户管理 Starter”快速入门,或者您可以复制/粘贴下面的 SQL 并运行它。

  1. 转到控制面板中的 SQL 编辑器 页面。
  2. 点击 社区 > 快速入门 选项卡下的 用户管理 Starter
  3. 点击 运行

获取 API 详细信息#

现在您已经创建了一些数据库表,您可以使用自动生成的 API 插入数据。

为此,你需要从 项目 Connect 对话框 获取项目 URL 和密钥。

阅读 API 密钥文档 以全面了解所有密钥类型及其用途。

构建应用#

从头开始构建 Next.js 应用。

初始化 Next.js 应用#

使用 create-next-app 初始化一个名为 supabase-nextjs 的应用

1
npx create-next-app@latest --ts --use-npm supabase-nextjs
2
cd supabase-nextjs

然后安装 Supabase 客户端库:supabase-js

1
npm install @supabase/supabase-js

将环境变量保存在项目根目录下的 .env.local 文件中,并粘贴你之前复制的 API URL 和密钥 此处

1
NEXT_PUBLIC_SUPABASE_URL=YOUR_SUPABASE_URL
2
NEXT_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 的包。

1
npm install @supabase/ssr

Supabase 工具#

Supabase 中有两种不同类型的客户端

  1. 客户端组件客户端 - 要从在浏览器中运行的客户端组件访问 Supabase。
  2. 服务器组件客户端 - 要从仅在服务器上运行的服务器组件、服务器操作和路由处理程序访问 Supabase。

建议创建以下基本工具文件来创建客户端,并在项目根目录下的 lib/supabase 文件夹中组织它们。

创建带有以下功能的 client.tsserver.ts 文件,分别用于客户端 Supabase 和服务器端 Supabase。

1
import { createBrowserClient } from "@supabase/ssr";
2
3
export function createClient() {
4
// Create a supabase client on the browser with project's credentials
5
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 匹配器文档

在项目根目录中创建一个 proxy.ts 文件,并在 lib/supabase 文件夹中再创建一个。lib/supabase 文件包含更新会话的逻辑。这由 proxy.ts 文件使用,该文件是 Next.js 约定。

1
import { type NextRequest } from 'next/server'
2
import { updateSession } from '@/lib/supabase/proxy'
3
4
export async function proxy(request: NextRequest) {
5
// update user's auth session
6
return await updateSession(request)
7
}
8
9
export 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 文件。

1
import { login, signup } from './actions'
2
3
export 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 作为注册请求,这反过来会发送一封确认电子邮件。
  • 处理任何出现的错误。

app/login 文件夹中创建 action.ts 文件,其中包含登录和注册函数以及 error/page.tsx 文件,如果登录或注册失败,则显示错误消息。

1
'use server'
2
3
import { revalidatePath } from 'next/cache'
4
import { redirect } from 'next/navigation'
5
6
import { createClient } from '@/lib/supabase/server'
7
8
export async function login(formData: FormData) {
9
const supabase = await createClient()
10
11
// type-casting here for convenience
12
// in practice, you should validate your inputs
13
const data = {
14
email: formData.get('email') as string,
15
password: formData.get('password') as string,
16
}
17
18
const { error } = await supabase.auth.signInWithPassword(data)
19
20
if (error) {
21
redirect('/error')
22
}
23
24
revalidatePath('/', 'layout')
25
redirect('/account')
26
}
27
28
export async function signup(formData: FormData) {
29
const supabase = await createClient()
30
31
// type-casting here for convenience
32
// in practice, you should validate your inputs
33
const data = {
34
email: formData.get('email') as string,
35
password: formData.get('password') as string,
36
}
37
38
const { error } = await supabase.auth.signUp(data)
39
40
if (error) {
41
redirect('/error')
42
}
43
44
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
1
import { type EmailOtpType } from '@supabase/supabase-js'
2
import { type NextRequest, NextResponse } from 'next/server'
3
import { createClient } from '@/lib/supabase/server'
4
5
// Creating a handler to a GET request to route /auth/confirm
6
export 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 | null
10
const next = '/account'
11
12
// Create redirect link without the secret token
13
const redirectTo = request.nextUrl.clone()
14
redirectTo.pathname = next
15
redirectTo.searchParams.delete('token_hash')
16
redirectTo.searchParams.delete('type')
17
18
if (token_hash && type) {
19
const supabase = await createClient()
20
21
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
}
30
31
// return the user to an error page with some instructions
32
redirectTo.pathname = '/error'
33
return NextResponse.redirect(redirectTo)
34
}
查看源代码

账户页面#

用户登录后,允许他们编辑他们的个人资料详细信息并管理他们的帐户。

app/account 文件夹中创建一个名为 AccountForm 的新组件。

app/account/account-form.tsx
1
'use client'
2
import { useCallback, useEffect, useState } from 'react'
3
import { createClient } from '@/lib/supabase/client'
4
import { type User } from '@supabase/supabase-js'
5
6
// ...
7
8
export 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)
15
16
const getProfile = useCallback(async () => {
17
try {
18
setLoading(true)
19
20
const { data, error, status } = await supabase
21
.from('profiles')
22
.select(`full_name, username, website, avatar_url`)
23
.eq('id', user?.id)
24
.single()
25
26
if (error && status !== 406) {
27
console.log(error)
28
throw error
29
}
30
31
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])
43
44
useEffect(() => {
45
getProfile()
46
}, [user, getProfile])
47
48
async function updateProfile({
49
username,
50
website,
51
avatar_url,
52
}: {
53
username: string | null
54
fullname: string | null
55
website: string | null
56
avatar_url: string | null
57
}) {
58
try {
59
setLoading(true)
60
61
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 error
70
alert('Profile updated!')
71
} catch (error) {
72
alert('Error updating the data!')
73
} finally {
74
setLoading(false)
75
}
76
}
77
78
return (
79
<div className="form-widget">
80
81
{/* ... */}
82
83
<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
<input
90
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
<input
99
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
<input
108
id="website"
109
type="url"
110
value={website || ''}
111
onChange={(e) => setWebsite(e.target.value)}
112
/>
113
</div>
114
115
<div>
116
<button
117
className="button primary block"
118
onClick={() => updateProfile({ fullname, username, website, avatar_url })}
119
disabled={loading}
120
>
121
{loading ? 'Loading ...' : 'Update'}
122
</button>
123
</div>
124
125
<div>
126
<form action="/auth/signout" method="post">
127
<button className="button block" type="submit">
128
Sign out
129
</button>
130
</form>
131
</div>
132
</div>
133
)
134
}
查看源代码

为刚刚创建的 AccountForm 组件创建一个帐户页面

app/account/page.tsx
1
import AccountForm from './account-form'
2
import { createClient } from '@/lib/supabase/server'
3
4
export default async function Account() {
5
const supabase = await createClient()
6
7
const {
8
data: { user },
9
} = await supabase.auth.getUser()
10
11
return <AccountForm user={user} />
12
}
查看源代码

注销#

创建一个路由处理程序来处理服务器端的注销,确保首先检查用户是否已登录。

app/auth/signout/route.ts
1
import { createClient } from "@/lib/supabase/server";
2
import { revalidatePath } from "next/cache";
3
import { type NextRequest, NextResponse } from "next/server";
4
5
export async function POST(req: NextRequest) {
6
const supabase = await createClient();
7
8
// Check if a user's logged in
9
const {
10
data: { user },
11
} = await supabase.auth.getUser();
12
13
if (user) {
14
await supabase.auth.signOut();
15
}
16
17
revalidatePath("/", "layout");
18
return NextResponse.redirect(new URL("/login", req.url), {
19
status: 302,
20
});
21
}
查看源代码

启动#

现在你已经准备好所有页面、路由处理程序和组件,在终端窗口中运行以下命令

1
npm run dev

然后打开浏览器到 localhost:3000/login,你应该看到完成的应用程序。

当你输入你的电子邮件和密码时,你将收到一封标题为 Confirm Your Signup 的电子邮件。恭喜 🎉!!!

奖励:个人资料照片#

每个 Supabase 项目都配置了 存储,用于管理大型文件,如照片和视频。

创建一个上传小部件#

为用户创建一个头像小部件,以便他们可以上传个人资料照片。首先创建一个新组件

app/account/avatar.tsx
1
'use client'
2
import React, { useEffect, useState } from 'react'
3
import { createClient } from '@/lib/supabase/client'
4
import Image from 'next/image'
5
6
export default function Avatar({
7
uid,
8
url,
9
size,
10
onUpload,
11
}: {
12
uid: string | null
13
url: string | null
14
size: number
15
onUpload: (url: string) => void
16
}) {
17
const supabase = createClient()
18
const [avatarUrl, setAvatarUrl] = useState<string | null>(url)
19
const [uploading, setUploading] = useState(false)
20
21
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 error
27
}
28
29
const url = URL.createObjectURL(data)
30
setAvatarUrl(url)
31
} catch (error) {
32
console.log('Error downloading image: ', error)
33
}
34
}
35
36
if (url) downloadImage(url)
37
}, [url, supabase])
38
39
const uploadAvatar: React.ChangeEventHandler<HTMLInputElement> = async (event) => {
40
try {
41
setUploading(true)
42
43
if (!event.target.files || event.target.files.length === 0) {
44
throw new Error('You must select an image to upload.')
45
}
46
47
const file = event.target.files[0]
48
const fileExt = file.name.split('.').pop()
49
const filePath = `${uid}-${Math.random()}.${fileExt}`
50
51
const { error: uploadError } = await supabase.storage.from('avatars').upload(filePath, file)
52
53
if (uploadError) {
54
throw uploadError
55
}
56
57
onUpload(filePath)
58
} catch (error) {
59
alert('Error uploading avatar!')
60
} finally {
61
setUploading(false)
62
}
63
}
64
65
return (
66
<div>
67
{avatarUrl ? (
68
<Image
69
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
<input
84
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
// ...
2
3
import Avatar from './avatar'
4
5
// ...
6
7
return (
8
<div className="form-widget">
9
<Avatar
10
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
/>
18
19
{/* ... */}
20
21
</div>
22
)
23
}
查看源代码

此时,您已经拥有一个功能齐全的应用!

另请参阅#