Skip to content
Getting Started

用 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 User Management example

项目设置 #

🌐 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

  1. 在 Supabase 仪表板中创建一个新项目
  2. 输入你的项目详情。
  3. 等新数据库上线。

设置数据库模式 #

🌐 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.

  1. 在仪表板中转到SQL 编辑器页面。
  2. 点击 社区 > 快速入门 标签下的 用户管理入门
  3. 点击运行

获取 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.

Project URL
Publishable key

正在构建应用 #

🌐 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):

1
npx sv create supabase-sveltekit
2
cd supabase-sveltekit
3
npm install

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

🌐 Then install the Supabase client library: supabase-js

1
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.

1
PUBLIC_SUPABASE_URL="YOUR_SUPABASE_URL"
2
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:

1
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:

1
// src/hooks.server.ts
2
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_PUBLISHABLE_KEY } from '$env/static/public'
3
import { createServerClient } from '@supabase/ssr'
4
import type { Handle } from '@sveltejs/kit'
5
6
export 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 the
12
* set and remove method due to sveltekit's cookie API
13
* requiring this to be set, setting the path to `/`
14
* will replicate previous/standard behaviour (https://kit.svelte.dev/docs/types#public-types-cookies)
15
*/
16
setAll: (cookiesToSet, headers) => {
17
cookiesToSet.forEach(({ name, value, options }) => {
18
event.cookies.set(name, value, { ...options, path: '/' })
19
})
20
if (Object.keys(headers).length > 0) {
21
event.setHeaders(headers)
22
}
23
},
24
},
25
})
26
27
return resolve(event, {
28
filterSerializedResponseHeaders(name: string) {
29
return name === 'content-range' || name === 'x-supabase-api-version'
30
},
31
})
32
}
View source

由于本教程使用 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:

1
import type { SupabaseClient } from '@supabase/supabase-js'
2
3
import type { Database } from './database.types'
4
5
// See https://kit.svelte.dev/docs/types#app
6
// for information about these interfaces
7
declare global {
8
namespace App {
9
// interface Error {}
10
interface Locals {
11
supabase: SupabaseClient<Database>
12
}
13
// interface PageState {}
14
// interface Platform {}
15
}
16
}
17
18
export {}
View source

创建一个新的 src/routes/+layout.server.ts 文件来处理服务器端的会话。

🌐 Create a new src/routes/+layout.server.ts file to handle the session on the server-side.

1
// src/routes/+layout.server.ts
2
import type { LayoutServerLoad } from './$types'
3
4
export const load: LayoutServerLoad = async ({ cookies }) => {
5
return {
6
cookies: cookies.getAll(),
7
}
8
}
View source

创建一个新的 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.

1
// src/routes/+layout.ts
2
import { PUBLIC_SUPABASE_PUBLISHABLE_KEY, PUBLIC_SUPABASE_URL } from '$env/static/public'
3
import type { LayoutLoad } from './$types'
4
import { createBrowserClient, createServerClient, isBrowser } from '@supabase/ssr'
5
6
export const load: LayoutLoad = async ({ fetch, data, depends }) => {
7
depends('supabase:auth')
8
9
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.cookies
22
},
23
},
24
})
25
26
/**
27
* `getClaims` validates the JWT signature locally (for asymmetric keys) once
28
* the relevant signing keys are available or cached, and returns the decoded
29
* claims. While an initial or periodic network request may be required to
30
* fetch or refresh keys, this is both faster and safer than `getSession`,
31
* which does not validate the JWT.
32
*/
33
const { data: claimsData, error } = await supabase.auth.getClaims()
34
const claims = error ? null : claimsData?.claims
35
36
return { supabase, claims }
37
}
View source

创建 src/routes/+layout.svelte

🌐 Create 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'
6
7
let { data, children } = $props()
8
let { supabase, claims } = $derived(data)
9
10
onMount(() => {
11
const { data } = supabase.auth.onAuthStateChange((event, _session) => {
12
if (_session?.expires_at !== claims?.exp) {
13
invalidate('supabase:auth')
14
}
15
})
16
17
return () => data.subscription.unsubscribe()
18
})
19
</script>
20
21
<svelte:head>
22
<title>User Management</title>
23
</svelte:head>
24
25
<div class="container" style="padding: 50px 0 100px 0">
26
{@render children()}
27
</div>
View source

设置一个登录页面 #

🌐 Set up a login page

通过更新 routes/+page.svelte 文件,为你的应用创建一个魔法链接登录/注册页面:

🌐 Create a magic link login/signup page for your application by updating the routes/+page.svelte file:

1
<!-- src/routes/+page.svelte -->
2
<script lang="ts">
3
import { enhance } from '$app/forms'
4
import type { ActionData, SubmitFunction } from './$types.js'
5
6
interface Props {
7
form: ActionData
8
}
9
let { form }: Props = $props()
10
11
let loading = $state(false)
12
13
const handleSubmit: SubmitFunction = () => {
14
loading = true
15
return async ({ update }) => {
16
update()
17
loading = false
18
}
19
}
20
</script>
21
22
<svelte:head>
23
<title>User Management</title>
24
</svelte:head>
25
26
<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>
View source

创建一个 src/routes/+page.server.ts 文件来处理提交时的魔法链接表单。

🌐 Create a src/routes/+page.server.ts file that handles the magic link form when submitted.

1
// src/routes/+page.server.ts
2
import { fail, redirect } from '@sveltejs/kit'
3
import type { Actions, PageServerLoad } from './$types'
4
5
export const load: PageServerLoad = async ({ url, locals: { supabase } }) => {
6
const { data, error } = await supabase.auth.getClaims()
7
8
// if the user is already logged in return them to the account page
9
if (!error && data?.claims) {
10
redirect(303, '/account')
11
}
12
13
return { url: url.origin }
14
}
15
16
export const actions: Actions = {
17
default: async (event) => {
18
const {
19
url,
20
request,
21
locals: { supabase },
22
} = event
23
const formData = await request.formData()
24
const email = formData.get('email') as string
25
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
}
30
31
const { error } = await supabase.auth.signInWithOtp({ email })
32
33
if (error) {
34
return fail(400, {
35
success: false,
36
email,
37
message: `There was an issue, Please contact support.`,
38
})
39
}
40
41
return {
42
success: true,
43
message: 'Please check your email for a magic link to log into the website.',
44
}
45
},
46
}
View source

电子邮件模板 #

🌐 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 模板重复上一步操作。

确认端点 #

🌐 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 页面。
1
// src/routes/auth/confirm/+server.js
2
import type { EmailOtpType } from '@supabase/supabase-js'
3
import { redirect } from '@sveltejs/kit'
4
5
import type { RequestHandler } from './$types'
6
7
export 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 | null
10
const next = url.searchParams.get('next') ?? '/account'
11
12
/**
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 = next
19
redirectTo.searchParams.delete('token_hash')
20
redirectTo.searchParams.delete('type')
21
22
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
}
29
30
redirectTo.pathname = '/auth/error'
31
redirect(303, redirectTo)
32
}
View source

身份验证出错页面 #

🌐 Authentication error page

如果确认令牌时出现错误,就把用户重定向到错误页面。

🌐 If there is an error with confirming the token, redirect the user to an error page.

1
<p>Login error</p>
View source

账户页面 #

🌐 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.

1
<script lang="ts">
2
import { enhance } from '$app/forms';
3
import type { SubmitFunction } from '@sveltejs/kit';
4
5
// ...
6
7
let { data, form } = $props()
8
let { claims, supabase, profile } = $derived(data)
9
let profileForm: HTMLFormElement
10
let loading = $state(false)
11
let fullName: string = profile?.full_name ?? ''
12
let username: string = profile?.username ?? ''
13
let website: string = profile?.website ?? ''
14
15
// ...
16
17
const handleSubmit: SubmitFunction = () => {
18
loading = true
19
return async ({ update }) => {
20
loading = false
21
update()
22
}
23
}
24
25
const handleSignOut: SubmitFunction = () => {
26
loading = true
27
return async ({ update }) => {
28
loading = false
29
update()
30
}
31
}
32
</script>
33
34
<div class="form-widget">
35
<form
36
class="form-widget"
37
method="post"
38
action="?/update"
39
use:enhance={handleSubmit}
40
bind:this={profileForm}
41
42
// ...
43
44
<div>
45
<label for="email">Email</label>
46
<input id="email" type="text" value={claims?.email ?? ''} disabled />
47
</div>
48
49
<div>
50
<label for="fullName">Full Name</label>
51
<input id="fullName" name="fullName" type="text" value={form?.fullName ?? fullName} />
52
</div>
53
54
<div>
55
<label for="username">Username</label>
56
<input id="username" name="username" type="text" value={form?.username ?? username} />
57
</div>
58
59
<div>
60
<label for="website">Website</label>
61
<input id="website" name="website" type="url" value={form?.website ?? website} />
62
</div>
63
64
<div>
65
<input
66
type="submit"
67
class="button block primary"
68
value={loading ? 'Loading...' : 'Update'}
69
disabled={loading}
70
/>
71
</div>
72
</form>
73
74
<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>
View source

现在,创建关联的 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.

src/routes/account/+page.server.ts
1
import { fail, redirect } from '@sveltejs/kit'
2
import type { Actions, PageServerLoad } from './$types'
3
4
export const load: PageServerLoad = async ({ locals: { supabase } }) => {
5
const { data: claimsData, error } = await supabase.auth.getClaims()
6
7
if (error || !claimsData?.claims) {
8
redirect(303, '/')
9
}
10
11
const { claims } = claimsData
12
13
const { data: profile } = await supabase
14
.from('profiles')
15
.select(`username, full_name, website, avatar_url`)
16
.eq('id', claims.sub)
17
.single()
18
19
return { claims, profile }
20
}
21
22
export const actions: Actions = {
23
update: async ({ request, locals: { supabase } }) => {
24
const formData = await request.formData()
25
const fullName = formData.get('fullName') as string
26
const username = formData.get('username') as string
27
const website = formData.get('website') as string
28
const avatarUrl = formData.get('avatarUrl') as string
29
30
const { data: claimsData, error: claimsError } = await supabase.auth.getClaims()
31
32
if (claimsError || !claimsData?.claims) {
33
return fail(401, { fullName, username, website, avatarUrl })
34
}
35
36
const { error } = await supabase.from('profiles').upsert({
37
id: claimsData.claims.sub,
38
full_name: fullName,
39
username,
40
website,
41
avatar_url: avatarUrl,
42
updated_at: new Date(),
43
})
44
45
if (error) {
46
return fail(500, {
47
fullName,
48
username,
49
website,
50
avatarUrl,
51
})
52
}
53
54
return {
55
fullName,
56
username,
57
website,
58
avatarUrl,
59
}
60
},
61
signout: async ({ locals: { supabase } }) => {
62
await supabase.auth.signOut()
63
redirect(303, '/')
64
},
65
}
View source

头像照片 #

🌐 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:

1
<!-- src/routes/account/Avatar.svelte -->
2
<script lang="ts">
3
import type { SupabaseClient } from '@supabase/supabase-js'
4
5
interface Props {
6
size?: number
7
url?: string
8
supabase: SupabaseClient
9
onupload?: () => void
10
}
11
let { size = 10, url = $bindable(), supabase, onupload }: Props = $props()
12
13
let avatarUrl: string | null = $state(null)
14
let uploading = $state(false)
15
let files: FileList = $state()
16
17
const downloadImage = async (path: string) => {
18
try {
19
const { data, error } = await supabase.storage.from('avatars').download(path)
20
21
if (error) {
22
throw error
23
}
24
25
const url = URL.createObjectURL(data)
26
avatarUrl = url
27
} catch (error) {
28
if (error instanceof Error) {
29
console.log('Error downloading image: ', error.message)
30
}
31
}
32
}
33
34
const uploadAvatar = async () => {
35
try {
36
uploading = true
37
38
if (!files || files.length === 0) {
39
throw new Error('You must select an image to upload.')
40
}
41
42
const file = files[0]
43
const fileExt = file.name.split('.').pop()
44
const filePath = `${Math.random()}.${fileExt}`
45
46
const { error } = await supabase.storage.from('avatars').upload(filePath, file)
47
48
if (error) {
49
throw error
50
}
51
52
url = filePath
53
setTimeout(() => {
54
onupload?.()
55
}, 100)
56
} catch (error) {
57
if (error instanceof Error) {
58
alert(error.message)
59
}
60
} finally {
61
uploading = false
62
}
63
}
64
65
$effect(() => {
66
if (url) downloadImage(url)
67
})
68
</script>
69
70
<div>
71
{#if avatarUrl}
72
<img
73
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} />
82
83
<div style="width: {size}em;">
84
<label class="button primary block" for="single">
85
{uploading ? 'Uploading ...' : 'Upload'}
86
</label>
87
<input
88
style="visibility: hidden; position:absolute;"
89
type="file"
90
id="single"
91
accept="image/*"
92
bind:files
93
onchange={uploadAvatar}
94
disabled={uploading}
95
/>
96
</div>
97
</div>
View source

更新账户页面 #

🌐 Update the account page

创建了 Avatar 组件后,更新 src/routes/account/+page.svelte 来包含它:

🌐 With the Avatar component created, update src/routes/account/+page.svelte to include it:

1
<script lang="ts">
2
import { enhance } from '$app/forms';
3
import type { SubmitFunction } from '@sveltejs/kit';
4
import Avatar from './Avatar.svelte'
5
6
let { data, form } = $props()
7
let { claims, supabase, profile } = $derived(data)
8
let profileForm: HTMLFormElement
9
let loading = $state(false)
10
let fullName: string = profile?.full_name ?? ''
11
let username: string = profile?.username ?? ''
12
let website: string = profile?.website ?? ''
13
let avatarUrl: string = $state(profile?.avatar_url ?? '')
14
15
const handleSubmit: SubmitFunction = () => {
16
loading = true
17
return async ({ update }) => {
18
loading = false
19
update()
20
}
21
}
22
23
const handleSignOut: SubmitFunction = () => {
24
loading = true
25
return async ({ update }) => {
26
loading = false
27
update()
28
}
29
}
30
</script>
31
32
<div class="form-widget">
33
<form
34
class="form-widget"
35
method="post"
36
action="?/update"
37
use:enhance={handleSubmit}
38
bind:this={profileForm}
39
>
40
<Avatar
41
{supabase}
42
bind:url={avatarUrl}
43
size={10}
44
onupload={() => {
45
profileForm.requestSubmit();
46
}}
47
/>
48
<input type="hidden" name="avatarUrl" value={avatarUrl} />
49
<div>
50
<label for="email">Email</label>
51
<input id="email" type="text" value={claims?.email ?? ''} disabled />
52
</div>
53
54
<div>
55
<label for="fullName">Full Name</label>
56
<input id="fullName" name="fullName" type="text" value={form?.fullName ?? fullName} />
57
</div>
58
59
<div>
60
<label for="username">Username</label>
61
<input id="username" name="username" type="text" value={form?.username ?? username} />
62
</div>
63
64
<div>
65
<label for="website">Website</label>
66
<input id="website" name="website" type="url" value={form?.website ?? website} />
67
</div>
68
69
<div>
70
<input
71
type="submit"
72
class="button block primary"
73
value={loading ? 'Loading...' : 'Update'}
74
disabled={loading}
75
/>
76
</div>
77
</form>
78
79
<form method="post" action="?/signout" use:enhance={handleSignOut}>
80
<div>
81
<button class="button block" disabled={loading}>Sign Out</button>
82
</div>
83
</form>
84
</div>
View source

触发! #

🌐 Launch!

所有页面都准备好后,在终端运行这个命令:

🌐 With all the pages in place, run this command in a terminal:

1
npm run dev

然后打开浏览器访问 localhost:5173,你应该能看到完成的应用。

🌐 And then open the browser to localhost:5173 and you should see the completed app.

Supabase Svelte

在这个阶段,你已经有了一个完全可用的应用!

🌐 At this stage you have a fully functional application!