首页
/ Supabase + Svelte 用户管理示例全解:从零搭建 Svelte 应用与 Postgres 行级安全鉴权

Supabase + Svelte 用户管理示例全解:从零搭建 Svelte 应用与 Postgres 行级安全鉴权

2026-09-06 19:20:19作者:瞿蔚英Wynne

本指南以 Supabase 官方示例项目 svelte-user-management 为蓝本,完整拆解如何在一个基于 Svelte 5 + Vite 的前端应用中集成 Supabase Auth、实时会话监听、Profiles 数据表 CRUD 与 Storage 头像上传。读完本文你将掌握从「创建项目、执行 Quickstart SQL、配置环境变量」到「Magic Link 登录、按用户写入个人资料、受 RLS 保护的头像存取」的端到端实战方案,并能在仓库源码层面理解每一行关键实现。

示例项目概览

examples/user-management/svelte-user-management 是 Supabase 仓库中 user-management 系列示例的 Svelte 实现。它演示了一条完整的用户管理链路:用户通过邮箱 Magic Link 登录 → 应用根据登录会话展示「登录」或「账户资料编辑」两种界面 → 用户可维护 usernamewebsiteavatar_url,并上传头像到 Storage。

项目的技术栈(以 package.json 为准):

依赖 版本区间 用途
svelte ^5.37.3 UI 框架(使用 Svelte 5 的 runes 语法)
@supabase/supabase-js ^2 官方 JS 客户端,负责 Auth、数据库与 Storage
vite ^7.0.6 开发服务器与构建工具
@sveltejs/vite-plugin-svelte ^6.1.0 Svelte 的 Vite 插件
typescript / svelte-check ~5.9.2 / ^4.3.1 类型检查

整个示例没有引入 SvelteKit,而是以纯 Vite + Svelte 的 SPA 形式组织,更聚焦于 Supabase 客户端 API 本身,便于单独抽出复用。

安装依赖与可用脚本

进入示例目录后先安装依赖:

npm install

安装完成后,项目目录下可用的脚本(对应 package.json):

命令 作用
npm run dev 以开发模式启动应用,浏览器打开 http://localhost:5173 即可访问;修改源码后页面会热更新重载
npm run build 构建生产版本,产物输出到 dist 文件夹。Vite 会在生产模式下正确打包 Svelte 并优化构建产物,文件名带内容哈希
npm run preview 本地预览生产构建产物
npm run check 执行 svelte-checktsc 做类型与编译期检查

Vite 的开发服务器默认监听 5173 端口,vite.config.ts 仅注册了 svelte() 插件,保持最小化配置。

从零搭建:五步快速上手

1. 创建 Supabase 项目

前往 Supabase Dashboard 注册并创建新项目,等待数据库启动完成。每个 Supabase 项目都是一个完整的 Postgres 数据库实例。

2. 运行 "User Management" Quickstart SQL

数据库启动后,进入项目的 SQL Editor,运行 User Management Starter 快速启动脚本(在 SQL editor 页面滚动找到 “User Management Starter: Sets up a public Profiles table which you can access with your API”),点击 RUN 执行。执行完毕后前往 Table Editor,即可看到新建的 profiles 表。

该脚本的关键内容包括三部分:

  1. 建表 profiles 并开启 RLS——每条用户记录与 auth.users 通过外键 id 关联;
  2. supabase_realtime 发布订阅添加 profiles——让该表支持 Realtime 实时推送;
  3. 创建 avatars Storage bucket 并配置公开读取/任意上传策略

3. 获取 API URL 与密钥

在 Project Settings(齿轮图标)→ API 页签中找到:

  • Project URL / API URL:形如 https://<project-ref>.supabase.co 的项目接口地址;
  • anon / publishable key:客户端 API 密钥。它允许用户在登录前对数据库进行「匿名访问」;一旦用户完成登录,密钥会切换为该用户自己的登录令牌,从而让 Postgres 行级安全(RLS)生效(详见下文「Postgres 行级安全」小节)。

安全警示secret(service_role)密钥拥有绕过一切安全策略的完整数据访问权限,必须严格保密,只允许在服务端环境中使用,绝不能放进客户端或浏览器代码。示例中的 .env.examplesupabaseClient.ts 只引用客户端密钥。

4. 配置环境变量

.env.example 复制生成 .env.local,并把上面的 URL 与密钥填入:

# .env.example 的内容(见 examples/user-management/svelte-user-management/.env.example)
VITE_SUPABASE_URL=https://your-project-ref.supabase.co
VITE_SUPABASE_PUBLISHABLE_KEY=your-publishable-key

.env.local 是本地开发私有文件,通常应加入 .gitignore,避免泄露密钥。Vite 会以 import.meta.env 的形式向客户端注入以 VITE_ 前缀开头的环境变量。

5. 运行应用

执行 npm run dev 并在浏览器打开 http://localhost:5173/,即进入可交互的登录界面。

客户端初始化与运行环境读取

src/supabaseClient.ts 是连接 Supabase 的唯一入口:

import { createClient } from '@supabase/supabase-js'

const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
const supabasePublishableKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY

export const supabase = createClient(supabaseUrl, supabasePublishableKey)
  • 通过 Vite 的类型化环境变量 import.meta.env.VITE_* 读取配置(类型声明位于 src/vite-env.d.ts);
  • createClient(url, key) 返回的 supabase 单例被各个组件共享,无需在组件内重复创建。

命名提示:SDK 新版本将此类客户端密钥统称为 publishable key,与旧文档中的 anon key 指代同一角色(公开、仅能触发 RLS 校验)。若在旧版文档中看到 VITE_SUPABASE_ANON_KEY 之类的命名,其职责等价。

会话状态管理与条件渲染

src/App.svelte 负责「是否已登录」的全局判断,是整条鉴权流程的入口:

<script lang="ts">
  import { onMount } from 'svelte'
  import { supabase } from './supabaseClient'
  import type { AuthSession } from '@supabase/supabase-js'
  import Account from './lib/Account.svelte'
  import Auth from './lib/Auth.svelte'

  let session = $state<AuthSession | null>(null)

  onMount(() => {
    supabase.auth.getSession().then(({ data }) => {
      session = data.session
    })

    supabase.auth.onAuthStateChange((_event, _session) => {
      session = _session
    })
  })
</script>

<div class="container" style="padding: 50px 0 100px 0">
  {#if !session}
  <Auth />
  {:else}
  <Account {session} />
  {/if}
</div>

可以对照官方用户管理模板的典型模式理解其内部机制:

  • 首次加载恢复会话onMount 中调用 supabase.auth.getSession() 读取本地持久化的登录态,把 sessionnull 更新为真实会话(或保持 null);
  • 订阅会话变化supabase.auth.onAuthStateChange 注册监听器,登录、登出、令牌刷新等事件都会触发回调并把最新 _session 写入响应式状态,保证 UI 与真实登录态始终一致;
  • Svelte 5 runeslet session = $state(...) 声明响应式变量,配合 {#if} 实现「未登录渲染 <Auth />、已登录渲染 <Account {session} />」的条件分支,同时把当前会话对象作为 prop 传给子组件。

应用入口 src/main.ts 使用 Svelte 5 新的 mount API 把 App 挂载到 #app 节点并导入全局样式 app.css

邮箱 Magic Link 无密码登录

src/lib/Auth.svelte 实现免密码登录:用户只需输入邮箱,点击发送 Magic Link,然后在邮箱中点击一次性登录链接即可完成登录。

<script lang="ts">
  import { supabase } from "../supabaseClient";

  let loading = $state(false);
  let email = $state("");

  const handleLogin = async () => {
    try {
      loading = true;
      const { error } = await supabase.auth.signInWithOtp({ email });
      if (error) throw error;
      alert("Check your email for login link!");
    } catch (error) {
      if (error instanceof Error) {
        alert(error.message);
      }
    } finally {
      loading = false;
    }
  };
</script>

<form class="form-widget" onsubmit={(e) => { e.preventDefault(); handleLogin(); }}>
  <input id="email" type="email" placeholder="Your email" bind:value={email} />
  <button type="submit" disabled={loading}>
    {loading ? "Loading" : "Send magic link"}
  </button>
</form>

核心调用是 supabase.auth.signInWithOtp({ email })

  • 发送请求期间通过 loading 状态禁用按钮并展示 “Loading”,用 finally 确保无论成败都会复位,避免重复提交;
  • 错误统一经 alert 弹出提示;
  • 表单通过 onsubmit + preventDefault() 拦截默认提交行为,转由 handleLogin 处理;
  • Magic Link 的启用/关闭取决于 Supabase 项目 Auth 配置中是否开启邮件 OTP,本示例默认走这一路径。

账户资料:读取与 upsert 更新

src/lib/Account.svelte 接收从 App.svelte 传入的 session,负责展示并编辑当前用户的资料。

读取当前用户资料getProfile):

const getProfile = async () => {
  loading = true;
  const { user } = session;

  const { data, error, status } = await supabase
    .from("profiles")
    .select("username, website, avatar_url")
    .eq("id", user.id)
    .single();

  if (error && status !== 406) throw error;

  if (data) {
    username = data.username;
    website = data.website;
    avatarUrl = data.avatar_url;
  }
};
  • session.user 取出登录用户的 id(该 UUID 与 auth.users 一致);
  • .eq("id", user.id) 过滤出当前用户的行,.single() 期望恰好返回一行;
  • 特殊处理 406:当该用户还没有资料行时,查询返回 406 Not Acceptable,此时不视为错误抛出,仅保持表单为空,等待用户新建资料。

保存资料updateProfile):

const updates = {
  id: user.id,
  username,
  website,
  avatar_url: avatarUrl,
  updated_at: new Date().toISOString(),
};

const { error } = await supabase.from("profiles").upsert(updates);

使用 upsert(存在则更新、不存在则插入)把表单内容写回 profiles 表,并手动刷新 updated_at 时间戳。这种「upsert + 主键即 user.id」的写法既避免了「先查后写」的竞态,也正好利用了 profiles.id 引用 auth.users.id 的主键约束。

页面同时提供 Sign Out 按钮,点击即调用 supabase.auth.signOut(),触发 onAuthStateChange 回调后 App.sveltesession 置空,界面自动切回登录视图。

头像上传:Storage bucket 与 Blob 预览

src/lib/Avatar.svelte 封装了一个可复用的头像组件,对外暴露 url(bindable)与 onupload 回调,账户页在头像更新完成后会触发 updateProfile 一并把新的 avatar_url 落库。

下载并预览downloadImage):

const { data, error } = await supabase.storage
  .from("avatars")
  .download(path);

const url = URL.createObjectURL(data);
avatarUrl = url;
  • avatars bucket 按对象路径下载文件;
  • URL.createObjectURL 生成本地 Blob URL 直接预览,无需对象公开 URL;
  • $effect(() => { if (url) downloadImage(url); }) 是 Svelte 5 的响应式副作用:每当传入的 url 变化(如新头像上传成功)就重新拉取图片。

上传文件uploadAvatar):

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);

url = filePath;
onupload?.();
  • 取用户选择的第一个文件,用 Math.random() + 原扩展名生成随机对象路径,避免同名文件互相覆盖与缓存冲突(生产项目更推荐 UUID、时间戳等方案);
  • 上传成功后把 url 回写给父组件($bindable)并调用 onupload?.() 通知保存资料;
  • 模板用隐藏的原生 <input type="file" accept="image/*"> 触发文件选择,样式上以按钮呈现。

整个链路可总结为:上传头像到 avatars bucket → 把对象路径存进 profiles.avatar_url → 下次进入时按路径 downloadcreateObjectURL 展示

Postgres 行级安全(RLS)原理

该示例的高层授权完全建立在 Postgres 行级安全(Row Level Security)之上,这是整个方案安全性的根基:

  • Supabase 中的每个 Postgres 数据库都预置了 auth schema 及若干辅助函数;
  • 用户登录后会获得一个携带角色 authenticated 与用户 UUID 的 JWT;
  • Supabase 据此对「每个用户能做什么、不能做什么」进行细粒度控制:数据库层面的 RLS 策略会基于 JWT 中的 auth.uid() 校验每一行数据的读写权限,而不是依赖客户端「隐藏界面元素」这类不可信的防御手段。

User Management Starter 生成的表结构与策略

以下是该示例所依赖的精简版 schema 及全部策略(与官方 Quickstart SQL 一致):

-- 创建公开资料表(Public Profiles)
create table
  profiles (
    id uuid references auth.users not null,
    updated_at timestamp with time zone,
    username text unique,
    avatar_url text,
    website text,
    primary key (id),
    unique (username),
    constraint username_length check (char_length(username) >= 3)
  );

alter table profiles enable row level security;

-- 所有人可查看公开资料(read 策略)
create policy "Public profiles are viewable by everyone." on profiles for
select
  using (true);

-- 用户只能插入自己的资料(insert 策略)
create policy "Users can insert their own profile." on profiles for insert
with
  check ((select auth.uid()) = id);

-- 用户只能更新自己的资料(update 策略)
create policy "Users can update own profile." on profiles for
update
  using ((select auth.uid()) = id);

-- 启用 Realtime:将 profiles 表加入 supabase_realtime 发布订阅
begin;
drop publication if exists supabase_realtime;
create publication supabase_realtime;
commit;
alter publication supabase_realtime add table profiles;

-- 创建 avatars Storage bucket
insert into
  storage.buckets (id, name)
values
  ('avatars', 'avatars');

-- Storage 访问控制:允许以公开方式下载头像对象
create policy "Avatar images are publicly accessible." on storage.objects for
select
  using (bucket_id = 'avatars' and storage.allow_any_operation(array['object.get_authenticated_info', 'object.get_authenticated']));

-- 允许任意人上传头像
create policy "Anyone can upload an avatar." on storage.objects for insert
with
  check (bucket_id = 'avatars');

逐个策略解读:

对象 策略 含义
profiles enable row level security 开启 RLS,此后所有普通客户端访问都必须经过策略裁决
profiles SELECT using (true) 公开资料对所有人可读(含未登录的 anon 角色),这是公开展示用户名的前提
profiles INSERT with check (auth.uid() = id) 只允许用户插入 id 等于自己 UUID 的行,防止越权创建他人资料
profiles UPDATE using (auth.uid() = id) 只允许用户更新自己的行;未提供 DELETE 策略,即默认禁止删除
profiles 加入 supabase_realtime 发布 开启该表的 Realtime 变更推送能力
storage.buckets 插入 avatars 创建公开的 avatars bucket
storage.objects SELECT bucket_id = 'avatars' 允许公开下载 avatars bucket 中的对象
storage.objects INSERT bucket_id = 'avatars' 允许向 avatars bucket 上传(示例保持开放以便快速体验,生产建议收紧为 auth.uid() = (storage.foldername(name))[1]::uuid 之类按用户隔离的策略)

理解这张表,也就理解了这个示例为何「安全不用在前端做任何权限判断」:即便恶意用户直接构造 API 请求,也会在数据库层被 RLS 拒绝。账户页对 status !== 406 的错误统一抛出、Storage 随机对象路径等细节,都是对这套权限模型的补充。

从源码到文档:示例目录结构速览

examples/user-management/svelte-user-management/
├── .env.example              # 环境变量模板(VITE_SUPABASE_URL / VITE_SUPABASE_PUBLISHABLE_KEY)
├── index.html
├── package.json              # 脚本与依赖声明
├── svelte.config.js
├── tsconfig*.json            # 应用与 Node 侧分离的 TS 配置
├── vite.config.ts            # 仅注册 svelte() 插件
└── src/
    ├── main.ts               # mount 入口
    ├── App.svelte            # 会话管理 + 登录/资料条件渲染
    ├── supabaseClient.ts     # createClient 单例
    ├── app.css               # 全局样式
    └── lib/
        ├── Auth.svelte       # Magic Link 邮箱登录
        ├── Account.svelte    # 资料读取与 upsert 更新、登出
        └── Avatar.svelte     # Storage 头像上传与 Blob 预览

同系列的其他语言实现(如 nextjs-user-managementexpo-user-management 等)均复用同一份 RLS schema 与用户管理交互模式,可互为参照。

进阶方向

在把本示例用于真实产品前,可以继续深化:

  1. 收紧上传策略:让用户只能把对象上传到以自己 auth.uid() 命名的目录(avatars/<uid>/...),并把 storage.objects 的 SELECT/INSERT 策略改为按目录校验;
  2. 补充资料删除与账户注销:为 profiles 增加 DELETE 策略,或通过 Database Trigger 在用户删除时级联清理资料与头像;
  3. 接入 Email 之外的登录方式signInWithOtp 之外,supabase.auth 还支持 OAuth(signInWithOAuth)、密码(signInWithPassword)等方式;
  4. 使用 Supabase CLI 管理迁移:把上述 Quickstart SQL 落到 supabase/migrations 目录,实现 schema 的版本化——仓库中其他 user-management 示例(如 nextjs、flutter)均以 migrations 的形式维护这份初始化 SQL,可对照参考。

整体来看,本示例的精髓在于「薄客户端 + 厚数据库」:前端只负责调用 Auth、读写 profiles、存取 Storage,而数据安全完全交由 Postgres RLS 在服务端裁决。把这份 schema 与组件模式迁移到自己的 Supabase + Svelte 项目,即可快速获得一套安全、可扩展的用户体系。

登录后查看全文
热门项目推荐
相关项目推荐