使用 SvelteKit 构建用户管理应用
本教程演示如何构建一个基本的用户管理应用。该应用对用户进行身份验证和识别,将他们的个人资料信息存储在数据库中,并允许用户登录、更新他们的个人资料详细信息以及上传个人资料照片。该应用使用
- 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 密钥文档 以全面了解所有密钥类型及其用途。
构建应用#
开始从头构建 Svelte 应用。
初始化 Svelte 应用#
使用 SvelteKit 骨架项目初始化一个名为 supabase-sveltekit 的应用(对于本教程,选择“SvelteKit minimal”并使用 TypeScript)
1npx sv create supabase-sveltekit2cd supabase-sveltekit3npm install然后安装 Supabase 客户端库:supabase-js
1npm install @supabase/supabase-js最后,将环境变量保存到 .env 文件中。您只需要 PUBLIC_SUPABASE_URL 和您之前复制的密钥 此处。
1PUBLIC_SUPABASE_URL="YOUR_SUPABASE_URL"2PUBLIC_SUPABASE_PUBLISHABLE_KEY="YOUR_SUPABASE_PUBLISHABLE_KEY"应用样式 (可选)#
一个可选步骤是更新 CSS 文件 src/styles.css 以使应用看起来更美观。您可以在 示例仓库中找到此文件的完整内容。
为 SSR 创建 Supabase 客户端#
ssr 包配置 Supabase 以使用 Cookie,这对于服务器端语言和框架是必需的。
安装 SSR 包
1npm install @supabase/ssr使用 ssr 包创建 Supabase 客户端会自动配置它以使用 Cookie。这意味着用户会话在整个 SvelteKit 堆栈中可用 - 页面、布局、服务器和钩子。
将以下代码添加到 src/hooks.server.ts 文件中,以在服务器上初始化客户端
1// src/hooks.server.ts2import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_PUBLISHABLE_KEY } from '$env/static/public'3import { createServerClient } from '@supabase/ssr'4import type { Handle } from '@sveltejs/kit'56export const handle: Handle = async ({ event, resolve }) => {7 event.locals.supabase = createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_PUBLISHABLE_KEY, {8 cookies: {9 getAll: () => event.cookies.getAll(),10 /**11 * Note: You have to add the `path` variable to the12 * set and remove method due to sveltekit's cookie API13 * requiring this to be set, setting the path to `/`14 * will replicate previous/standard behaviour (https://kit.svelte.net.cn/docs/types#public-types-cookies)15 */16 setAll: (cookiesToSet) => {17 cookiesToSet.forEach(({ name, value, options }) => {18 event.cookies.set(name, value, { ...options, path: '/' })19 })20 },21 },22 })2324 /**25 * Unlike `supabase.auth.getSession`, which is unsafe on the server because it26 * doesn't validate the JWT, this function validates the JWT by first calling27 * `getUser` and aborts early if the JWT signature is invalid.28 */29 event.locals.safeGetSession = async () => {30 const {31 data: { user },32 error,33 } = await event.locals.supabase.auth.getUser()34 if (error) {35 return { session: null, user: null }36 }3738 const {39 data: { session },40 } = await event.locals.supabase.auth.getSession()41 return { session, user }42 }4344 return resolve(event, {45 filterSerializedResponseHeaders(name: string) {46 return name === 'content-range' || name === 'x-supabase-api-version'47 },48 })49}请注意,auth.getSession 从本地存储介质读取身份验证令牌和未编码的会话数据。除非本地会话已过期,否则它不会向 Supabase Auth 服务器发送请求。
如果您正在编写服务器代码,则绝不应该信任未编码的会话数据,因为它可能被发送者篡改。如果您需要经过验证的可信用户数据,请调用 auth.getUser,它始终向 Auth 服务器发出请求以获取可信数据。
由于本教程使用 TypeScript,编译器会抱怨 event.locals.supabase 和 event.locals.safeGetSession,您可以通过使用以下内容更新 src/app.d.ts 来修复此问题
1import { , } from '@supabase/supabase-js'2// See https://kit.svelte.net.cn/docs/types#app3// for information about these interfaces4declare {5 namespace {6 // interface Error {}7 interface {8 : 9 (): <{ : | null; ?: ["user"] | null }>10 }11 interface {12 : | null13 ?: ["user"] | null14 }15 // interface PageState {}16 // interface Platform {}17 }18}1920export {};创建一个新的 src/routes/+layout.server.ts 文件来处理服务器端的会话。
1// src/routes/+layout.server.ts2import type { LayoutServerLoad } from './$types'34export const load: LayoutServerLoad = async ({ locals: { safeGetSession }, cookies }) => {5 const { session, user } = await safeGetSession()67 return {8 session,9 user,10 cookies: cookies.getAll(),11 }12}启动开发服务器 (npm run dev) 以生成我们在项目中引用的 ./$types 文件。
创建一个新的 src/routes/+layout.ts 文件来处理客户端的会话和 supabase 对象。
1// src/routes/+layout.ts2import { PUBLIC_SUPABASE_PUBLISHABLE_KEY, PUBLIC_SUPABASE_URL } from '$env/static/public'3import type { LayoutLoad } from './$types'4import { createBrowserClient, createServerClient, isBrowser } from '@supabase/ssr'56export const load: LayoutLoad = async ({ fetch, data, depends }) => {7 depends('supabase:auth')89 const supabase = isBrowser()10 ? createBrowserClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_PUBLISHABLE_KEY, {11 global: {12 fetch,13 },14 })15 : createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_PUBLISHABLE_KEY, {16 global: {17 fetch,18 },19 cookies: {20 getAll() {21 return data.cookies22 },23 },24 })2526 /**27 * It's fine to use `getSession` here, because on the client, `getSession` is28 * safe, and on the server, it reads `session` from the `LayoutData`, which29 * safely checked the session using `safeGetSession`.30 */31 const {32 data: { session },33 } = await supabase.auth.getSession()3435 return { supabase, session }36}创建 src/routes/+layout.svelte
1<!-- src/routes/+layout.svelte -->2<script lang="ts">3 import '../styles.css'4 import { invalidate } from '$app/navigation'5 import { onMount } from 'svelte'67 let { data, children } = $props()8 let { supabase, session } = $derived(data)910 onMount(() => {11 const { data } = supabase.auth.onAuthStateChange((event, _session) => {12 if (_session?.expires_at !== session?.expires_at) {13 invalidate('supabase:auth')14 }15 })1617 return () => data.subscription.unsubscribe()18 })19</script>2021<svelte:head>22 <title>User Management</title>23</svelte:head>2425<div class="container" style="padding: 50px 0 100px 0">26 {@render children()}27</div>设置登录页面#
通过更新 routes/+page.svelte 文件来为您的应用程序创建一个魔法链接登录/注册页面
1<!-- src/routes/+page.svelte -->2<script lang="ts">3 import { enhance } from '$app/forms'4 import type { ActionData, SubmitFunction } from './$types.js'56 interface Props {7 form: ActionData8 }9 let { form }: Props = $props()1011 let loading = $state(false)1213 const handleSubmit: SubmitFunction = () => {14 loading = true15 return async ({ update }) => {16 update()17 loading = false18 }19 }20</script>2122<svelte:head>23 <title>User Management</title>24</svelte:head>2526<form class="row flex flex-center" method="POST" use:enhance={handleSubmit}>27 <div class="col-6 form-widget">28 <h1 class="header">Supabase + SvelteKit</h1>29 <p class="description">Sign in via magic link with your email below</p>30 {#if form?.message !== undefined}31 <div class="success {form?.success ? '' : 'fail'}">32 {form?.message}33 </div>34 {/if}35 <div>36 <label for="email">Email address</label>37 <input 38 id="email" 39 name="email" 40 class="inputField" 41 type="email" 42 placeholder="Your email" 43 value={form?.email ?? ''} 44 />45 </div>46 {#if form?.errors?.email}47 <span class="flex items-center text-sm error">48 {form?.errors?.email}49 </span>50 {/if}51 <div>52 <button class="button primary block">53 { loading ? 'Loading' : 'Send magic link' }54 </button>55 </div>56 </div>57</form>创建一个 src/routes/+page.server.ts 文件,该文件在提交魔法链接表单时处理该表单。
1// src/routes/+page.server.ts2import { fail, redirect } from '@sveltejs/kit'3import type { Actions, PageServerLoad } from './$types'45export const load: PageServerLoad = async ({ url, locals: { safeGetSession } }) => {6 const { session } = await safeGetSession()78 // if the user is already logged in return them to the account page9 if (session) {10 redirect(303, '/account')11 }1213 return { url: url.origin }14}1516export const actions: Actions = {17 default: async (event) => {18 const {19 url,20 request,21 locals: { supabase }22 } = event23 const formData = await request.formData()24 const email = formData.get('email') as string25 const validEmail = /^[\w-\.+]+@([\w-]+\.)+[\w-]{2,8}$/.test(email)26 27 if (!validEmail) {28 return fail(400, { errors: { email: "Please enter a valid email address" }, email })29 }3031 const { error } = await supabase.auth.signInWithOtp({ email })3233 if (error) {34 return fail(400, {35 success: false,36 email,37 message: `There was an issue, Please contact support.`38 })39 }4041 return {42 success: true,43 message: 'Please check your email for a magic link to log into the website.'44 }45 }46}电子邮件模板#
更改电子邮件模板以支持服务器端身份验证流程。
在继续之前,让我们更改电子邮件模板以支持发送令牌哈希
- 转到项目仪表板中的 Auth > Emails 页面。
- 选择 Confirm signup 模板。
- 将
{{ .ConfirmationURL }}更改为{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email。 - 对 Magic link 模板重复上一步。
您知道吗? 您还可以自定义发送给新用户的电子邮件,包括电子邮件的外观、内容和查询参数。请查看您的项目设置。
确认端点#
由于这是一个服务器端渲染 (SSR) 环境,您需要创建一个服务器端点来负责用 token_hash 交换会话。
以下代码片段执行以下步骤
- 从 Supabase Auth 服务器使用
token_hash查询参数检索发送回的token_hash。 - 将此
token_hash交换为会话,您将其存储在存储中(在本例中为 Cookie)。 - 最后,将用户重定向到
account页面或error页面。
1// src/routes/auth/confirm/+server.js2import type { EmailOtpType } from '@supabase/supabase-js'3import { redirect } from '@sveltejs/kit'45import type { RequestHandler } from './$types'67export const GET: RequestHandler = async ({ url, locals: { supabase } }) => {8 const token_hash = url.searchParams.get('token_hash')9 const type = url.searchParams.get('type') as EmailOtpType | null10 const next = url.searchParams.get('next') ?? '/account'1112 /**13 * Clean up the redirect URL by deleting the Auth flow parameters.14 *15 * `next` is preserved for now, because it's needed in the error case.16 */17 const redirectTo = new URL(url)18 redirectTo.pathname = next19 redirectTo.searchParams.delete('token_hash')20 redirectTo.searchParams.delete('type')2122 if (token_hash && type) {23 const { error } = await supabase.auth.verifyOtp({ type, token_hash })24 if (!error) {25 redirectTo.searchParams.delete('next')26 redirect(303, redirectTo)27 }28 }2930 redirectTo.pathname = '/auth/error'31 redirect(303, redirectTo)32}身份验证错误页面#
如果确认令牌出现错误,请将用户重定向到错误页面。
1<p>Login error</p>账户页面#
用户登录后,他们需要能够编辑他们的个人资料详细信息页面。使用以下内容创建一个新的 src/routes/account/+page.svelte 文件。
1<script lang="ts">2 import { enhance } from '$app/forms';3 import type { SubmitFunction } from '@sveltejs/kit';45 // ...67 let { data, form } = $props()8 let { session, supabase, profile } = $derived(data)9 let profileForm: HTMLFormElement10 let loading = $state(false)11 let fullName: string = profile?.full_name ?? ''12 let username: string = profile?.username ?? ''13 let website: string = profile?.website ?? ''1415 // ...1617 const handleSubmit: SubmitFunction = () => {18 loading = true19 return async () => {20 loading = false21 }22 }2324 const handleSignOut: SubmitFunction = () => {25 loading = true26 return async ({ update }) => {27 loading = false28 update()29 }30 }31</script>3233<div class="form-widget">34 <form35 class="form-widget"36 method="post"37 action="?/update"38 use:enhance={handleSubmit}39 bind:this={profileForm}40 >4142 // ...4344 <div>45 <label for="email">Email</label>46 <input id="email" type="text" value={session.user.email} disabled />47 </div>4849 <div>50 <label for="fullName">Full Name</label>51 <input id="fullName" name="fullName" type="text" value={form?.fullName ?? fullName} />52 </div>5354 <div>55 <label for="username">Username</label>56 <input id="username" name="username" type="text" value={form?.username ?? username} />57 </div>5859 <div>60 <label for="website">Website</label>61 <input id="website" name="website" type="url" value={form?.website ?? website} />62 </div>6364 <div>65 <input66 type="submit"67 class="button block primary"68 value={loading ? 'Loading...' : 'Update'}69 disabled={loading}70 />71 </div>72 </form>7374 <form method="post" action="?/signout" use:enhance={handleSignOut}>75 <div>76 <button class="button block" disabled={loading}>Sign Out</button>77 </div>78 </form>79</div>现在,创建关联的 src/routes/account/+page.server.ts 文件,该文件通过 load 函数处理从服务器加载数据,并通过 actions 对象处理所有表单操作。
src/routes/account/+page.server.ts
1import { fail, redirect } from '@sveltejs/kit'2import type { Actions, PageServerLoad } from './$types'34export const load: PageServerLoad = async ({ locals: { supabase, safeGetSession } }) => {5 const { session } = await safeGetSession()67 if (!session) {8 redirect(303, '/')9 }1011 const { data: profile } = await supabase12 .from('profiles')13 .select(`username, full_name, website, avatar_url`)14 .eq('id', session.user.id)15 .single()1617 return { session, profile }18}1920export const actions: Actions = {21 update: async ({ request, locals: { supabase, safeGetSession } }) => {22 const formData = await request.formData()23 const fullName = formData.get('fullName') as string24 const username = formData.get('username') as string25 const website = formData.get('website') as string26 const avatarUrl = formData.get('avatarUrl') as string2728 const { session } = await safeGetSession()2930 const { error } = await supabase.from('profiles').upsert({31 id: session?.user.id,32 full_name: fullName,33 username,34 website,35 avatar_url: avatarUrl,36 updated_at: new Date(),37 })3839 if (error) {40 return fail(500, {41 fullName,42 username,43 website,44 avatarUrl,45 })46 }4748 return {49 fullName,50 username,51 website,52 avatarUrl,53 }54 },55 signout: async ({ locals: { supabase, safeGetSession } }) => {56 const { session } = await safeGetSession()57 if (session) {58 await supabase.auth.signOut()59 redirect(303, '/')60 }61 },62}启动!#
准备好所有页面后,在终端中运行此命令
1npm run dev然后打开浏览器到 localhost:5173,您应该会看到完成的应用。

奖励:个人资料照片#
每个 Supabase 项目都配置了 存储,用于管理大型文件,如照片和视频。
创建一个上传小部件#
创建一个用户头像,以便他们可以上传个人资料照片。首先在 src/routes/account 目录中创建一个名为 Avatar.svelte 的新组件
1<!-- src/routes/account/Avatar.svelte -->2<script lang="ts">3 import type { SupabaseClient } from '@supabase/supabase-js'45 interface Props {6 size?: number7 url?: string8 supabase: SupabaseClient9 onupload?: () => void10 }11 let { size = 10, url = $bindable(), supabase, onupload }: Props = $props()1213 let avatarUrl: string | null = $state(null)14 let uploading = $state(false)15 let files: FileList = $state()1617 const downloadImage = async (path: string) => {18 try {19 const { data, error } = await supabase.storage.from('avatars').download(path)2021 if (error) {22 throw error23 }2425 const url = URL.createObjectURL(data)26 avatarUrl = url27 } catch (error) {28 if (error instanceof Error) {29 console.log('Error downloading image: ', error.message)30 }31 }32 }3334 const uploadAvatar = async () => {35 try {36 uploading = true3738 if (!files || files.length === 0) {39 throw new Error('You must select an image to upload.')40 }4142 const file = files[0]43 const fileExt = file.name.split('.').pop()44 const filePath = `${Math.random()}.${fileExt}`4546 const { error } = await supabase.storage.from('avatars').upload(filePath, file)4748 if (error) {49 throw error50 }5152 url = filePath53 setTimeout(() => {54 onupload?.()55 }, 100)56 } catch (error) {57 if (error instanceof Error) {58 alert(error.message)59 }60 } finally {61 uploading = false62 }63 }6465 $effect(() => {66 if (url) downloadImage(url)67 })68</script>6970<div>71 {#if avatarUrl}72 <img73 src={avatarUrl}74 alt={avatarUrl ? 'Avatar' : 'No image'}75 class="avatar image"76 style="height: {size}em; width: {size}em;"77 />78 {:else}79 <div class="avatar no-image" style="height: {size}em; width: {size}em;"></div>80 {/if}81 <input type="hidden" name="avatarUrl" value={url} />8283 <div style="width: {size}em;">84 <label class="button primary block" for="single">85 {uploading ? 'Uploading ...' : 'Upload'}86 </label>87 <input88 style="visibility: hidden; position:absolute;"89 type="file"90 id="single"91 accept="image/*"92 bind:files93 onchange={uploadAvatar}94 disabled={uploading}95 />96 </div>97</div>添加新的小部件#
将小部件添加到 Account 页面
1<script lang="ts">23 // ...45 import Avatar from './Avatar.svelte'67// ...89<div class="form-widget">1011 // ...1213 <Avatar14 {supabase}15 bind:url={avatarUrl}16 size={10}17 onupload={() => {18 profileForm.requestSubmit();19 }}20 />2122// ...2324</div>此时,您已经拥有一个功能齐全的应用!