用 SvelteKit 构建一个用户管理应用
本教程演示了如何构建一个基本的用户管理应用。该应用可以进行用户认证和识别,将用户的个人资料信息存储在数据库中,并允许用户登录、更新他们的个人资料信息以及上传头像。该应用使用了:
🌐 This tutorial demonstrates how to build a basic user management app. The app authenticates and identifies the user, stores their profile information in the database, and allows the user to log in, update their profile details, and upload a profile photo. The app uses:
- Supabase 数据库 - 一个用于存储用户数据的 Postgres 数据库,并且有 行级安全,所以数据是受保护的,用户只能访问自己的信息。
- Supabase Auth - 允许用户注册和登录。
- Supabase 存储 - 允许用户上传个人头像。

如果你在跟着这个指南操作时遇到困难,你可以在GitHub上找到完整示例。
🌐 If you get stuck while working through this guide, you can find the full example on GitHub.
项目设置 #
🌐 Project setup
在你开始构建之前,你需要先设置数据库和 API。你可以通过在 Supabase 中启动一个新项目,然后在数据库中创建一个“架构”来完成这一步。
🌐 Before you start building you need to set up the Database and API. You can do this by starting a new Project in Supabase and then creating a "schema" inside the database.
创建一个项目 #
🌐 Create a project
- 在 Supabase 仪表板中创建一个新项目。
- 输入你的项目详情。
- 等新数据库上线。
设置数据库模式 #
🌐 Set up the database schema
现在设置数据库模式。你可以在 SQL 编辑器中使用“用户管理入门”快速开始,也可以复制粘贴下面的 SQL 并运行。
🌐 Now set up the database schema. You can use the "User Management Starter" quickstart in the SQL Editor, or you can copy/paste the SQL from below and run it.
- 在仪表板中转到SQL 编辑器页面。
- 在 参考 > 示例 选项卡下点击 用户管理入门。
- 点击运行。
你可以通过运行 db pull 命令将数据库架构拉到本地项目。查看本地开发文档获取详细说明。
🌐 You can pull the database schema down to your local project by running the db pull command. Read the local development docs for detailed instructions.
supabase link --project-ref <project-id># You can get <project-id> from your project's dashboard URL: https://supabase.nodejs.cn/dashboard/project/<project-id>supabase db pull获取 API 详情 #
🌐 Get API details
要与数据库表中的数据进行交互,你可以使用封装了自动生成的数据 API 端点的客户端库,并使用来自项目 Connect 对话框的项目 URL 和密钥进行认证。
🌐 To interact with data in database tables, you use the client libraries that wrap the auto-generated Data API endpoints, authenticating using the Project URL and key from the project Connect dialog.
正在构建应用 #
🌐 Building the app
从零开始构建 Svelte 应用。
🌐 Start building the Svelte app from scratch.
初始化一个 Svelte 应用 #
🌐 Initialize a Svelte app
使用 SvelteKit Skeleton Project 初始化一个名为 supabase-sveltekit 的应用(在本教程中,选择“SvelteKit minimal”并使用 TypeScript):
🌐 Use the SvelteKit Skeleton Project to initialize an app called supabase-sveltekit (for this tutorial, select "SvelteKit minimal" and use TypeScript):
npx sv create supabase-sveltekitcd supabase-sveltekitnpm install然后安装 Supabase 客户端库:supabase-js
🌐 Then install the Supabase client library: supabase-js
npm install @supabase/supabase-js最后,将环境变量保存在一个 .env 文件中。你只需要 PUBLIC_SUPABASE_URL 和之前复制的密钥 earlier 就可以了。
🌐 And finally, save the environment variables in a .env file.
All you need are the PUBLIC_SUPABASE_URL and the key that you copied earlier.
PUBLIC_SUPABASE_URL="YOUR_SUPABASE_URL"PUBLIC_SUPABASE_PUBLISHABLE_KEY="YOUR_SUPABASE_PUBLISHABLE_KEY"应用风格(可选) #
🌐 App styling (optional)
一个可选步骤是更新 CSS 文件 src/styles.css,让应用看起来更好看。你可以在示例仓库中找到这个文件的完整内容。
🌐 An optional step is to update the CSS file src/styles.css to make the app look nice.
You can find the full contents of this file in the example repository.
为 SSR 创建 Supabase 客户端 #
🌐 Creating a Supabase client for SSR
ssr 包配置 Supabase 使用 Cookies,这对于服务器端语言和框架是必需的。
🌐 The ssr package configures Supabase to use Cookies, which are required for server-side languages and frameworks.
安装 SSR 包:
🌐 Install the SSR package:
npm install @supabase/ssr使用 ssr 包创建 Supabase 客户端时,它会自动配置为使用 Cookies。这意味着用户的会话可以在整个 SvelteKit 栈中使用——页面、布局、服务器和钩子。
🌐 Creating a Supabase client with the ssr package automatically configures it to use Cookies. This means the user's session is available throughout the entire SvelteKit stack - page, layout, server, and hooks.
将下面的代码添加到一个 src/hooks.server.ts 文件中,以在服务器上初始化客户端:
🌐 Add the code below to a src/hooks.server.ts file to initialize the client on the server:
// src/hooks.server.tsimport { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_PUBLISHABLE_KEY } from '$env/static/public'import { createServerClient } from '@supabase/ssr'import type { Handle } from '@sveltejs/kit'export const handle: Handle = async ({ event, resolve }) => { event.locals.supabase = createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_PUBLISHABLE_KEY, { cookies: { getAll: () => event.cookies.getAll(), /** * Note: You have to add the `path` variable to the * set and remove method due to sveltekit's cookie API * requiring this to be set, setting the path to `/` * will replicate previous/standard behaviour (https://kit.svelte.dev/docs/types#public-types-cookies) */ setAll: (cookiesToSet, headers) => { cookiesToSet.forEach(({ name, value, options }) => { event.cookies.set(name, value, { ...options, path: '/' }) }) if (Object.keys(headers).length > 0) { event.setHeaders(headers) } }, }, }) return resolve(event, { filterSerializedResponseHeaders(name: string) { return name === 'content-range' || name === 'x-supabase-api-version' }, })}注意,auth.getSession 会从本地存储介质读取认证令牌和未编码的会话数据。除非本地会话过期,它是不会向 Supabase Auth 服务器发送请求的。
🌐 Note that auth.getSession reads the auth token and the unencoded session data from the local storage medium. It doesn't send a request back to the Supabase Auth server unless the local session is expired.
如果你在写服务器代码,绝对不要相信未编码的会话数据,因为发送方可能会篡改它。如果你需要经过验证、可靠的用户数据,应该调用 auth.getUser,它总是向认证服务器请求获取可信数据。
🌐 You should never trust the unencoded session data if you're writing server code, since it could be tampered with by the sender. If you need verified, trustworthy user data, call auth.getUser instead, which always makes a request to the Auth server to fetch trusted data.
由于本教程使用 TypeScript,编译器会对 event.locals.supabase 报错。你可以通过用下面的内容更新 src/app.d.ts 来解决这个问题:
🌐 As this tutorial uses TypeScript the compiler complains about event.locals.supabase. You can fix this by updating the src/app.d.ts with the content below:
import type { SupabaseClient } from '@supabase/supabase-js'import type { Database } from './database.types'// See https://kit.svelte.dev/docs/types#app// for information about these interfacesdeclare global { namespace App { // interface Error {} interface Locals { supabase: SupabaseClient<Database> } // interface PageState {} // interface Platform {} }}export {}创建一个新的 src/routes/+layout.server.ts 文件来处理服务器端的会话。
🌐 Create a new src/routes/+layout.server.ts file to handle the session on the server-side.
// src/routes/+layout.server.tsimport type { LayoutServerLoad } from './$types'export const load: LayoutServerLoad = async ({ cookies }) => { return { cookies: cookies.getAll(), }}启动开发服务器(npm run dev)来生成我们在项目中引用的 ./$types 文件。
🌐 Start the dev server (npm run dev) to generate the ./$types files we are referencing in our project.
创建一个新的 src/routes/+layout.ts 文件来处理客户端的会话和 supabase 对象。
🌐 Create a new src/routes/+layout.ts file to handle the session and the supabase object on the client-side.
// src/routes/+layout.tsimport { PUBLIC_SUPABASE_PUBLISHABLE_KEY, PUBLIC_SUPABASE_URL } from '$env/static/public'import type { LayoutLoad } from './$types'import { createBrowserClient, createServerClient, isBrowser } from '@supabase/ssr'export const load: LayoutLoad = async ({ fetch, data, depends }) => { depends('supabase:auth') const supabase = isBrowser() ? createBrowserClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_PUBLISHABLE_KEY, { global: { fetch, }, }) : createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_PUBLISHABLE_KEY, { global: { fetch, }, cookies: { getAll() { return data.cookies }, }, }) /** * `getClaims` validates the JWT signature locally (for asymmetric keys) once * the relevant signing keys are available or cached, and returns the decoded * claims. While an initial or periodic network request may be required to * fetch or refresh keys, this is both faster and safer than `getSession`, * which does not validate the JWT. */ const { data: claimsData, error } = await supabase.auth.getClaims() const claims = error ? null : claimsData?.claims return { supabase, claims }}创建 src/routes/+layout.svelte:
🌐 Create src/routes/+layout.svelte:
<!-- src/routes/+layout.svelte --><script lang="ts"> import '../styles.css' import { invalidate } from '$app/navigation' import { onMount } from 'svelte' let { data, children } = $props() let { supabase, claims } = $derived(data) onMount(() => { const { data } = supabase.auth.onAuthStateChange((event, _session) => { if (_session?.expires_at !== claims?.exp) { invalidate('supabase:auth') } }) return () => data.subscription.unsubscribe() })</script><svelte:head> <title>User Management</title></svelte:head><div class="container" style="padding: 50px 0 100px 0"> {@render children()}</div>设置一个登录页面 #
🌐 Set up a sign-in page
通过更新 routes/+page.svelte 文件,为你的应用创建一个魔法链接登录/注册页面:
🌐 Create a magic link sign-in/sign-up page for your application by updating the routes/+page.svelte file:
<!-- src/routes/+page.svelte --><script lang="ts"> import { enhance } from '$app/forms' import type { ActionData, SubmitFunction } from './$types.js' interface Props { form: ActionData } let { form }: Props = $props() let loading = $state(false) const handleSubmit: SubmitFunction = () => { loading = true return async ({ update }) => { update() loading = false } }</script><svelte:head> <title>User Management</title></svelte:head><form class="row flex flex-center" method="POST" use:enhance={handleSubmit}> <div class="col-6 form-widget"> <h1 class="header">Supabase + SvelteKit</h1> <p class="description">Sign in via magic link with your email below</p> {#if form?.message !== undefined} <div class="success {form?.success ? '' : 'fail'}"> {form?.message} </div> {/if} <div> <label for="email">Email address</label> <input id="email" name="email" class="inputField" type="email" placeholder="Your email" value={form?.email ?? ''} /> </div> {#if form?.errors?.email} <span class="flex items-center text-sm error"> {form?.errors?.email} </span> {/if} <div> <button class="button primary block"> { loading ? 'Loading' : 'Send magic link' } </button> </div> </div></form>创建一个 src/routes/+page.server.ts 文件来处理提交时的魔法链接表单。
🌐 Create a src/routes/+page.server.ts file that handles the magic link form when submitted.
// src/routes/+page.server.tsimport { fail, redirect } from '@sveltejs/kit'import type { Actions, PageServerLoad } from './$types'export const load: PageServerLoad = async ({ url, locals: { supabase } }) => { const { data, error } = await supabase.auth.getClaims() // if the user is already logged in return them to the account page if (!error && data?.claims) { redirect(303, '/account') } return { url: url.origin }}export const actions: Actions = { default: async (event) => { const { url, request, locals: { supabase }, } = event const formData = await request.formData() const email = formData.get('email') as string const validEmail = /^[\w-\.+]+@([\w-]+\.)+[\w-]{2,8}$/.test(email) if (!validEmail) { return fail(400, { errors: { email: 'Please enter a valid email address' }, email }) } const { error } = await supabase.auth.signInWithOtp({ email }) if (error) { return fail(400, { success: false, email, message: `There was an issue, Please contact support.`, }) } return { success: true, message: 'Please check your email for a magic link to log into the website.', } },}电子邮件模板 #
🌐 Email template
把电子邮件模板改成支持服务器端认证流程。
🌐 Change the email template to support a server-side authentication flow.
在继续之前,先修改一下邮件模板,让它支持发送令牌哈希:
🌐 Before proceeding, change the email template to support sending a token hash:
- 在项目控制面板中,进入 Auth > Emails 页面。
- 选择 确认注册 模板。
- 把
{{ .ConfirmationURL }}改成{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email。 - 对 Magic link 模板重复上一步操作。
你知道吗?
你还可以自定义发送给新用户的邮件,包括邮件的外观、内容和查询参数。查看你项目的设置。
🌐 You can also customize emails sent out to new users, including the email's looks, content, and query parameters. Check out the settings of your project.
确认端点 #
🌐 Confirmation endpoint
由于这是一个服务器端渲染(SSR)环境,你需要创建一个服务器端点来负责将 token_hash 兑换为会话。
🌐 As this is a server-side rendering (SSR) environment, you need to create a server endpoint responsible for exchanging the token_hash for a session.
下面的代码片段执行以下步骤:
🌐 The following code snippet performs the following steps:
- 使用
token_hash查询参数从 Supabase Auth 服务器获取返回的token_hash。 - 用这个
token_hash交换一个会话,然后你把它存储起来(在这个例子里是存到 cookies 里)。 - 最后,把用户重定向到
account页面或error页面。
// src/routes/auth/confirm/+server.jsimport type { EmailOtpType } from '@supabase/supabase-js'import { redirect } from '@sveltejs/kit'import type { RequestHandler } from './$types'export const GET: RequestHandler = async ({ url, locals: { supabase } }) => { const token_hash = url.searchParams.get('token_hash') const type = url.searchParams.get('type') as EmailOtpType | null const next = url.searchParams.get('next') ?? '/account' /** * Clean up the redirect URL by deleting the Auth flow parameters. * * `next` is preserved for now, because it's needed in the error case. */ const redirectTo = new URL(url) redirectTo.pathname = next redirectTo.searchParams.delete('token_hash') redirectTo.searchParams.delete('type') if (token_hash && type) { const { error } = await supabase.auth.verifyOtp({ type, token_hash }) if (!error) { redirectTo.searchParams.delete('next') redirect(303, redirectTo) } } redirectTo.pathname = '/auth/error' redirect(303, redirectTo)}身份验证出错页面 #
🌐 Authentication error page
如果确认令牌时出现错误,就把用户重定向到错误页面。
🌐 If there is an error with confirming the token, redirect the user to an error page.
<p>Login error</p>账户页面 #
🌐 Account page
用户登录后,需要能够编辑他们的个人资料详情页。
创建一个新的 src/routes/account/+page.svelte 文件,内容如下。
🌐 After a user signs in, they need to be able to edit their profile details page.
Create a new src/routes/account/+page.svelte file with the content below.
<script lang="ts"> import { enhance } from '$app/forms'; import type { SubmitFunction } from '@sveltejs/kit'; // ... let { data, form } = $props() let { claims, supabase, profile } = $derived(data) let profileForm: HTMLFormElement let loading = $state(false) let fullName: string = profile?.full_name ?? '' let username: string = profile?.username ?? '' let website: string = profile?.website ?? '' // ... const handleSubmit: SubmitFunction = () => { loading = true return async ({ update }) => { loading = false update() } } const handleSignOut: SubmitFunction = () => { loading = true return async ({ update }) => { loading = false update() } }</script><div class="form-widget"> <form class="form-widget" method="post" action="?/update" use:enhance={handleSubmit} bind:this={profileForm} // ... <div> <label for="email">Email</label> <input id="email" type="text" value={claims?.email ?? ''} disabled /> </div> <div> <label for="fullName">Full Name</label> <input id="fullName" name="fullName" type="text" value={form?.fullName ?? fullName} /> </div> <div> <label for="username">Username</label> <input id="username" name="username" type="text" value={form?.username ?? username} /> </div> <div> <label for="website">Website</label> <input id="website" name="website" type="url" value={form?.website ?? website} /> </div> <div> <input type="submit" class="button block primary" value={loading ? 'Loading...' : 'Update'} disabled={loading} /> </div> </form> <form method="post" action="?/signout" use:enhance={handleSignOut}> <div> <button class="button block" disabled={loading}>Sign Out</button> </div> </form></div>现在,创建关联的 src/routes/account/+page.server.ts 文件,通过 load 函数从服务器加载数据,并通过 actions 对象处理所有表单操作。
🌐 Now, create the associated src/routes/account/+page.server.ts file that handles loading data from the server through the load function
and handle all form actions through the actions object.
import { fail, redirect } from '@sveltejs/kit'import type { Actions, PageServerLoad } from './$types'export const load: PageServerLoad = async ({ locals: { supabase } }) => { const { data: claimsData, error } = await supabase.auth.getClaims() if (error || !claimsData?.claims) { redirect(303, '/') } const { claims } = claimsData const { data: profile } = await supabase .from('profiles') .select(`username, full_name, website, avatar_url`) .eq('id', claims.sub) .single() return { claims, profile }}export const actions: Actions = { update: async ({ request, locals: { supabase } }) => { const formData = await request.formData() const fullName = formData.get('fullName') as string const username = formData.get('username') as string const website = formData.get('website') as string const avatarUrl = formData.get('avatarUrl') as string const { data: claimsData, error: claimsError } = await supabase.auth.getClaims() if (claimsError || !claimsData?.claims) { return fail(401, { fullName, username, website, avatarUrl }) } const { error } = await supabase.from('profiles').upsert({ id: claimsData.claims.sub, full_name: fullName, username, website, avatar_url: avatarUrl, updated_at: new Date(), }) if (error) { return fail(500, { fullName, username, website, avatarUrl, }) } return { fullName, username, website, avatarUrl, } }, signout: async ({ locals: { supabase } }) => { await supabase.auth.signOut() redirect(303, '/') },}头像照片 #
🌐 Profile photos
接下来,添加一个让用户上传个人资料照片的方式。Supabase 会为每个项目配置 Storage,用于管理像照片和视频这样的大文件。
🌐 Next, add a way for users to upload a profile photo. Supabase configures every project with Storage for managing large files like photos and videos.
创建一个上传小工具 #
🌐 Create an upload widget
首先在 src/routes/account 目录下创建一个名为 Avatar.svelte 的新组件:
🌐 Start by creating a new component called Avatar.svelte in the src/routes/account directory:
<!-- src/routes/account/Avatar.svelte --><script lang="ts"> import type { SupabaseClient } from '@supabase/supabase-js' interface Props { size?: number url?: string supabase: SupabaseClient onupload?: () => void } let { size = 10, url = $bindable(), supabase, onupload }: Props = $props() let avatarUrl: string | null = $state(null) let uploading = $state(false) let files: FileList = $state() const downloadImage = async (path: string) => { try { const { data, error } = await supabase.storage.from('avatars').download(path) if (error) { throw error } const url = URL.createObjectURL(data) avatarUrl = url } catch (error) { if (error instanceof Error) { console.log('Error downloading image: ', error.message) } } } const uploadAvatar = async () => { try { uploading = true if (!files || files.length === 0) { throw new Error('You must select an image to upload.') } const file = files[0] const fileExt = file.name.split('.').pop() const filePath = `${Math.random()}.${fileExt}` const { error } = await supabase.storage.from('avatars').upload(filePath, file) if (error) { throw error } url = filePath setTimeout(() => { onupload?.() }, 100) } catch (error) { if (error instanceof Error) { alert(error.message) } } finally { uploading = false } } $effect(() => { if (url) downloadImage(url) })</script><div> {#if avatarUrl} <img src={avatarUrl} alt={avatarUrl ? 'Avatar' : 'No image'} class="avatar image" style="height: {size}em; width: {size}em;" /> {:else} <div class="avatar no-image" style="height: {size}em; width: {size}em;"></div> {/if} <input type="hidden" name="avatarUrl" value={url} /> <div style="width: {size}em;"> <label class="button primary block" for="single"> {uploading ? 'Uploading ...' : 'Upload'} </label> <input style="visibility: hidden; position:absolute;" type="file" id="single" accept="image/*" bind:files onchange={uploadAvatar} disabled={uploading} /> </div></div>更新账户页面 #
🌐 Update the account page
创建了 Avatar 组件后,更新 src/routes/account/+page.svelte 来包含它:
🌐 With the Avatar component created, update src/routes/account/+page.svelte to include it:
<script lang="ts"> import { enhance } from '$app/forms'; import type { SubmitFunction } from '@sveltejs/kit'; import Avatar from './Avatar.svelte' let { data, form } = $props() let { claims, supabase, profile } = $derived(data) let profileForm: HTMLFormElement let loading = $state(false) let fullName: string = profile?.full_name ?? '' let username: string = profile?.username ?? '' let website: string = profile?.website ?? '' let avatarUrl: string = $state(profile?.avatar_url ?? '') const handleSubmit: SubmitFunction = () => { loading = true return async ({ update }) => { loading = false update() } } const handleSignOut: SubmitFunction = () => { loading = true return async ({ update }) => { loading = false update() } }</script><div class="form-widget"> <form class="form-widget" method="post" action="?/update" use:enhance={handleSubmit} bind:this={profileForm} > <Avatar {supabase} bind:url={avatarUrl} size={10} onupload={() => { profileForm.requestSubmit(); }} /> <input type="hidden" name="avatarUrl" value={avatarUrl} /> <div> <label for="email">Email</label> <input id="email" type="text" value={claims?.email ?? ''} disabled /> </div> <div> <label for="fullName">Full Name</label> <input id="fullName" name="fullName" type="text" value={form?.fullName ?? fullName} /> </div> <div> <label for="username">Username</label> <input id="username" name="username" type="text" value={form?.username ?? username} /> </div> <div> <label for="website">Website</label> <input id="website" name="website" type="url" value={form?.website ?? website} /> </div> <div> <input type="submit" class="button block primary" value={loading ? 'Loading...' : 'Update'} disabled={loading} /> </div> </form> <form method="post" action="?/signout" use:enhance={handleSignOut}> <div> <button class="button block" disabled={loading}>Sign Out</button> </div> </form></div>触发! #
🌐 Launch!
所有页面都准备好后,在终端运行这个命令:
🌐 With all the pages in place, run this command in a terminal:
npm run dev然后打开浏览器访问 localhost:5173,你应该能看到完成的应用。
🌐 And then open the browser to localhost:5173 and you should see the completed app.

在这个阶段,你已经有了一个完全可用的应用!
🌐 At this stage you have a fully functional application!