使用 Refine + Supabase Auth 与 Twilio 构建 React OTP 短信登录
【免费下载链接】refineA React Framework for building internal tools, admin panels, dashboards & B2B apps with unmatched flexibility.项目地址: https://gitcode.com/GitHub_Trending/re/refine
导读
本指南基于 Refine 仓库中的完整示例项目 examples/blog-refine-supabase-auth,手把手实现一套基于 OTP(短信一次性密码)的免密码登录流程:前端使用 Refine 框架与 shadcn/ui 组件库,后端认证与数据层全部交由 Supabase Auth 承担,短信下发通过 Twilio Verify 完成。读完本文,你将掌握supabase.auth.signInWithOtp()与supabase.auth.verifyOtp()的完整调用链、如何编写面向 OTP 场景的 RefineauthProvider、如何使用 Refine 内置的 Supabase data provider 与 live provider,以及如何用Authenticated+NavigateToResource搭建受保护的路由树。
为什么选择 OTP 认证
密码是目前最主流的用户认证方式,但它有两个显著短板:一是容易遭受暴力破解与数据泄露攻击;二是用户需要在不同站点之间维护大量密码,记忆负担很重。OTP(One Time Password,一次性密码)正是针对这些问题的一种补充方案——当用户访问应用时,系统通过自动语音或短信下发一个六位数字验证码,用户输入后即可完成登录,全程不涉及密码的创建与保管。
在 Refine 中接入 OTP 认证尤其自然:Refine 内置了对 Supabase 的开箱即用支持,既包括数据层(data provider),也包括实时层(live provider),再加上 Refine 的authProvider抽象,认证逻辑可以被收敛到少数几个方法中,业务页面则通过useLogin、useLogout等 hook 无感知地消费。
前置条件
开始之前,请确认以下条件:
- 系统已安装最新版本的 Node.js;
- 熟悉 React 与 TypeScript 基础概念;
- 拥有一个 Twilio 账户,用于实际发送 OTP 短信;
- 拥有一个 GitHub 账户,用于注册 Supabase(Supabase 支持 GitHub 登录注册)。
创建 Refine 应用
使用官方脚手架工具create refine-app快速生成项目骨架:
npm create refine-app@latest refine-supabase-auth在交互式提示中按如下方式选择:
✔ Downloaded remote source successfully. ✔ Choose a project template · Refine(Vite) ✔ What would you like to name your project?: · refine-supabase-auth ✔ Choose your backend service to connect: · Supabase ✔ Do you want to use a UI Framework?: · shadcn/ui ✔ Do you want to add example pages?: · no ✔ Choose a package manager: · npm关键选择说明:
- Backend service选
Supabase:脚手架会自动安装@refinedev/supabase包,并生成dataProvider、liveProvider与supabaseClient的接线代码; - UI Framework选
shadcn/ui:提供本项目需要的Card、Input、Button、Table、Skeleton等基础组件; - Example pages选
no:示例页面会干扰我们即将手动编写的登录页与资源页。
项目就绪后进入目录并启动开发服务器:
cd refine-supabase-auth npm run dev脚手架已经替你接好了路由、主题、通知(notification provider)、Devtools 与实时(live)provider 等基础设施,你只需要在既定结构上补充业务代码。
配置 Supabase 项目与 Twilio Verify
获取 Supabase 凭据
登录 Supabase:
// Supabase Dashboard -> Integrations -> Data API export const SUPABASE_URL = "YOUR SUPABASE URL"; // Supabase Dashboard -> Project Settings -> API Keys -> Publishable key export const SUPABASE_KEY = "YOUR SUPABASE KEY";提示:仓库中的示例文件保留了一组演示凭据(constants.ts),实际开发时请务必替换为你自己项目的 URL 与 Key。
启用 Phone Auth 并接入 Twilio
进入Authentication → Sign In / Auth providers,找到Phone Auth选项并启用,在 SMS provider 中选择 Twilio。务必使用 Twilio Verify 配置,而不是通用的 Twilio Messaging 配置——Supabase 的 Phone Auth 依赖 Verify API 来完成验证码的创建、发送与校验闭环。
Supabase 需要你提供以下三个来自 Twilio Verify 的值:
| 配置项 | 获取方式 | 格式特征 |
|---|---|---|
| Twilio Account SID | Twilio Console 项目凭据区域 | 以AC开头 |
| Twilio Auth Token | 同一凭据区域 | 与 Account SID 配套 |
| Twilio Verify Service SID | Twilio Console → Verify → Services,创建 Verify 服务后复制 | 以VA开头 |
将三个值填入 Supabase 的 Twilio 字段后,其余 OTP 设置(如验证码有效期、码长、短信模板)可按需调整;本指南保持默认值即可。
客户端初始化
脚手架生成的 Supabase 客户端位于 src/providers/supabase-client.ts:
import { createClient } from "@refinedev/supabase"; import { SUPABASE_KEY, SUPABASE_URL } from "@/providers/constants"; export const supabaseClient = createClient(SUPABASE_URL, SUPABASE_KEY, { db: { schema: "public", }, auth: { persistSession: true, }, });注意这里导入的createClient来自@refinedev/supabase包而非@supabase/supabase-js。从 packages/supabase/src/index.ts 的源码可以看到,该包重新导出了@supabase/supabase-js的createClient,因此你既可以使用标准 supabase-js 的全部能力,也能在同一个客户端实例上无缝衔接 Refine 的 data provider 与 live provider:
// packages/supabase/src/index.ts import { createClient } from "@supabase/supabase-js"; import {} from "@supabase/supabase-js/dist/main/lib/types"; export * from "./types/index.js"; export * from "./utils/index.js"; export * from "./dataProvider/index.js"; export * from "./liveProvider/index.js"; export { createClient };创建登录页:两步 OTP 表单
登录页由两个步骤组成:
- 发送验证码:用户输入带国家区号的手机号,点击Send OTP触发短信下发;
- 输入验证码:用户输入收到的六位 OTP,点击Login完成登录;若验证码无效或已过期,页面展示错误信息。
完整实现见 src/pages/login.tsx:
import { useLogin } from "@refinedev/core"; import { type FormEvent, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { supabaseClient } from "@/providers/supabase-client"; let persistedLoginError: string | null = null; let persistedMobileNo = ""; let persistedFormState: "SEND_OTP" | "LOGIN" = "SEND_OTP"; export const LoginPage = () => { const mobileNoRef = useRef(persistedMobileNo); const otpRef = useRef(""); const [error, setError] = useState<string | null>(() => persistedLoginError); const [formState, setFormState] = useState<"SEND_OTP" | "LOGIN">( () => persistedFormState, ); const { mutate: login, isPending } = useLogin(); const setFormError = (message: string | null) => { persistedLoginError = message; setError(message); }; const setPersistedFormState = (nextState: "SEND_OTP" | "LOGIN") => { persistedFormState = nextState; setFormState(nextState); }; const backToSendOtp = () => { otpRef.current = ""; setFormError(null); setPersistedFormState("SEND_OTP"); }; const onLogin = () => { login( { mobileNo: mobileNoRef.current, otp: otpRef.current }, { onSuccess: ({ success, error }) => { if (!success) { setFormError(error?.message ?? error?.name ?? "Login failed"); return; } setFormError(null); persistedMobileNo = ""; setPersistedFormState("SEND_OTP"); }, onError: (error) => setFormError(error.message), }, ); }; const onSubmit = (event: FormEvent<HTMLFormElement>) => { event.preventDefault(); if (formState === "SEND_OTP") { void onSendOtp(); return; } onLogin(); }; const mobileFormRender = () => ( <div className="space-y-4"> <div className="space-y-2"> <Label htmlFor="mobile">Enter your mobile number</Label> <Input id="mobile" onChange={(e) => { mobileNoRef.current = e.target.value; persistedMobileNo = e.target.value; }} name="mobile" type="tel" defaultValue={mobileNoRef.current} placeholder="+14155552671" /> </div> <Button className="w-full" type="submit"> Send OTP </Button> </div> ); const otpFormRender = () => ( <div className="space-y-4"> <div className="rounded-md border border-border bg-muted/40 px-3 py-2 text-sm text-muted-foreground"> Code sent to{" "} <span className="font-medium text-foreground"> {mobileNoRef.current} </span> </div> <div className="space-y-2"> <Label htmlFor="otp">Enter OTP</Label> <Input id="otp" onChange={(e) => { otpRef.current = e.target.value; }} name="otp" defaultValue={otpRef.current} placeholder="123456" /> </div> <div className="flex gap-2"> <Button className="flex-1" type="submit" disabled={isPending}> Login </Button> <Button className="flex-1" type="button" variant="outline" disabled={isPending} onClick={backToSendOtp} > Change Number </Button> </div> </div> ); const onSendOtp = async () => { const mobileNo = mobileNoRef.current || ""; if (!/^\+[1-9]{1}[0-9]{3,14}$/.test(mobileNo)) { setFormError("Please enter a valid mobile number"); return; } const { error } = await supabaseClient.auth.signInWithOtp({ phone: mobileNo, }); if (error) { setFormError(error.message); return; } setFormError(null); setPersistedFormState("LOGIN"); }; return ( <div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background px-4 py-10"> <div className="pointer-events-none absolute inset-0 bg-gradient-to-b from-zinc-100 via-background to-background dark:from-zinc-900/60 dark:via-background dark:to-background" /> <Card className="relative z-10 w-full max-w-md border-border/70 bg-card/95 shadow-xl shadow-zinc-950/5 backdrop-blur dark:shadow-black/25"> <CardHeader className="space-y-1"> <CardTitle>Sign In</CardTitle> <CardDescription> {formState === "SEND_OTP" ? "Enter your phone number to receive a one-time password." : "Enter the one-time password sent to your phone."} </CardDescription> </CardHeader> <CardContent className="space-y-4"> {error ? ( <div className="rounded-md border border-destructive/20 bg-destructive/10 px-3 py-2 text-sm text-destructive"> {error} </div> ) : null} <form className="space-y-4" onSubmit={onSubmit}> {formState === "SEND_OTP" && mobileFormRender()} {formState === "LOGIN" && otpFormRender()} </form> </CardContent> </Card> </div> ); };实现要点:
formState驱动双阶段渲染:"SEND_OTP"阶段渲染手机号输入框,"LOGIN"阶段渲染 OTP 输入框;两个阶段的输入值分别保存在mobileNoRef与otpRef两个 ref 中,避免因受控输入引发不必要的重渲染;- 模块级持久化变量:
persistedLoginError、persistedMobileNo、persistedFormState三个顶层变量用于在路由切换(例如输入错误后跳转再返回)时保留表单状态,减少用户重复输入; - 手机号校验正则
^\+[1-9]{1}[0-9]{3,14}$:要求号码以+开头并包含国家区号,总长度为 4~15 位数字。若需更友好的体验,可以替换为带国家区号下拉框和内置校验的第三方手机号输入组件; - 发送验证码:直接调用
supabaseClient.auth.signInWithOtp({ phone }),由 Supabase 后台对接 Twilio Verify 完成短信下发;请求失败时把error.message渲染在卡片内; useLogin的isPending:登录请求进行中时禁用登录与换号按钮,避免重复提交。
数据提供层:Refine 内置的 Supabase data provider
dataProvider是应用的数据层,负责发起 HTTP 请求并封装数据的获取方式,通过create()、getList()、getMany()等预定义方法对外提供服务。Refine 的数据 hooks(如useList)内部会调用 data provider 的对应方法——例如使用useList时,Refine 会调用getList()。
本项目的数据提供层由 src/providers/data.ts 创建:
import { dataProvider as supabaseDataProvider } from "@refinedev/supabase"; import { supabaseClient } from "@/providers/supabase-client"; export const dataProvider = supabaseDataProvider(supabaseClient);sudabaseDataProvider是 Refine 对 Supabase 的开箱即用实现,位于 packages/supabase/src/dataProvider/index.ts。从源码结构看,getList的实现逻辑如下:
- 支持
meta.schema指定数据库 schema,未指定时默认使用客户端配置的 schema; - 服务端分页模式(
pagination.mode === "server")下通过query.range()映射 Refine 的currentPage/pageSize到 Supabase 的行区间; - 排序通过
query.order()完成,还支持foreignTable.field形式的关联表字段排序; - 过滤条件由 utils/generateFilter.ts 转换为 Supabase 的
eq、in、gte、contains等查询操作符; - 错误统一经
handleError处理后再抛给 Refine 数据层。
换言之,data provider 内部实际调用的是 supabase-js 的数据库方法(如select()、insert()、update()、delete()),Refine 的useList、useCreate等 hooks 让你无需关心这些底层调用。更完整的 data provider 概念说明可参见仓库中的 data 文档目录。
认证提供层:面向 OTP 流程的 authProvider
authProvider是 Refine 用于认证与授权的核心对象。本应用最关键的方法是login、logout、check、getIdentity与onError,它们返回 Promise,并被useLogin()、useLogout()等 hooks 内部调用。
选择 Supabase 模板时,脚手架已经生成了 src/providers/auth.ts,我们只需要针对 OTP 流程定制其中的login方法:
import type { AuthProvider } from "@refinedev/core"; import { supabaseClient } from "@/providers/supabase-client"; const authProvider: AuthProvider = { login: async ({ mobileNo, otp }) => { const { data, error } = await supabaseClient.auth.verifyOtp({ phone: mobileNo, token: otp, type: "sms", }); if (error) { return { success: false, error: error || { message: "Login failed", name: "Invalid OTP", }, }; } if (data.session) { return { success: true, redirectTo: "/", }; } return { success: false, error: { message: "Login failed", name: "Invalid OTP", }, }; }, logout: async () => { const { error } = await supabaseClient.auth.signOut(); if (error) { return { success: false, error: error || { message: "Logout failed", name: "Unexpected error", }, }; } return { success: true, redirectTo: "/login", }; }, onError: async (error) => { if (error?.code === "PGRST301" || error?.code === 401) { return { logout: true, }; } return { error }; }, check: async () => { const { data, error } = await supabaseClient.auth.getSession(); const session = data.session; if (!session) { return { authenticated: false, error: error || { message: "Check failed", name: "Session not found", }, redirectTo: "/login", }; } return { authenticated: true, }; }, getPermissions: async () => { const { data } = await supabaseClient.auth.getUser(); return data.user?.role ?? null; }, getIdentity: async () => { const { data } = await supabaseClient.auth.getUser(); const user = data.user; if (!user) { return null; } return { ...user, name: user.phone ?? user.email, }; }, }; export default authProvider;各方法职责梳理:
login:接收登录页传入的{ mobileNo, otp },调用supabaseClient.auth.verifyOtp({ phone, token, type: "sms" })校验验证码。type: "sms"告诉 Supabase 校验的是短信通道下发的验证码。校验成功后拿到data.session(Supabase 会自动写入并持久化会话),返回{ success: true, redirectTo: "/" };logout:调用supabase.auth.signOut()清除本地会话并重定向回/login;check:通过getSession()判断当前是否已登录,这是Authenticated组件与路由守卫的核心依据;onError:当遇到 Supabase 的PGRST301(JWT 过期)或 HTTP 401 时返回{ logout: true },触发 Refine 自动登出流程;getIdentity:把 Supabase 用户对象映射为 Refine 身份信息,并优先以手机号作为显示名称。
回到登录页的onLogin():它把手机号与 OTP 一并交给useLogin返回的login函数,Refine 内部会调用上述authProvider.login,并将结果透传给onSuccess/onError回调。验证码无效或过期时,authProvider返回success: false与错误对象,登录页据此渲染错误提示。
关于 authProvider 的完整方法约定,可进一步阅读仓库中的 authentication 文档目录。
配置路由:用 Authenticated 保护业务页面
在 src/App.tsx 中引入<LoginPage />并为其创建/login路由。同时借助<Authenticated />、<Outlet />、<CatchAllNavigate />与<NavigateToResource />实现"未登录跳转登录页、已登录自动跳转资源页"的路由守卫:
import { Authenticated, ErrorComponent, GitHubBanner, Refine, } from "@refinedev/core"; import { DevtoolsPanel, DevtoolsProvider } from "@refinedev/devtools"; import { RefineKbar, RefineKbarProvider } from "@refinedev/kbar"; import routerProvider, { CatchAllNavigate, DocumentTitleHandler, NavigateToResource, UnsavedChangesNotifier, } from "@refinedev/react-router"; import { liveProvider } from "@refinedev/supabase"; import { BrowserRouter, Routes, Route, Outlet } from "react-router"; import { Toaster } from "@/components/refine-ui/notification/toaster"; import { useNotificationProvider } from "@/components/refine-ui/notification/use-notification-provider"; import { ThemeProvider } from "@/components/refine-ui/theme/theme-provider"; import { Colors } from "@/pages/colors"; import { Layout } from "@/pages/layout"; import { LoginPage } from "@/pages/login"; import authProvider from "@/providers/auth"; import { dataProvider } from "@/providers/data"; import { supabaseClient } from "@/providers/supabase-client"; import "./App.css"; function App() { return ( <BrowserRouter> {/* <GitHubBanner /> */} <RefineKbarProvider> <ThemeProvider> <DevtoolsProvider> <Refine routerProvider={routerProvider} dataProvider={dataProvider} liveProvider={liveProvider(supabaseClient)} authProvider={authProvider} notificationProvider={useNotificationProvider()} resources={[ { name: "colors", list: "/colors", }, ]} options={{ syncWithLocation: true, warnWhenUnsavedChanges: true, }} > <Routes> <Route element={ <Authenticated key="authenticated-routes" fallback={<CatchAllNavigate to="/login" />} > <Layout> <Outlet /> </Layout> </Authenticated> } > <Route index element={<NavigateToResource resource="colors" />} /> <Route path="/colors" element={<Colors />} /> </Route> <Route element={ <Authenticated key="auth-pages" fallback={<Outlet />}> <NavigateToResource resource="colors" /> </Authenticated> } > <Route path="/login" element={<LoginPage />} /> </Route> <Route element={ <Authenticated key="catch-all" fallback={<CatchAllNavigate to="/login" />} > <Layout> <Outlet /> </Layout> </Authenticated> } > <Route path="*" element={<ErrorComponent />} /> </Route> </Routes> <Toaster /> <RefineKbar /> <UnsavedChangesNotifier /> <DocumentTitleHandler /> </Refine> <DevtoolsPanel /> </DevtoolsProvider> </ThemeProvider> </RefineKbarProvider> </BrowserRouter> ); } export default App;路由树的三层结构解读:
- 受保护路由组:
/(重定向到colors资源)与/colors被Authenticated包裹,未登录时经CatchAllNavigate跳转/login; - 登录路由组:
/login的守卫逻辑相反——已登录用户访问登录页会被NavigateToResource直接送回colors; - 兜底路由组:未匹配路径统一渲染
ErrorComponent,同样要求登录。
注意liveProvider={liveProvider(supabaseClient)}:Refine 的 Supabase live provider 会让依赖数据的组件订阅 Supabase 的实时变更,这是脚手架免费附带的能力之一。
创建 colors 资源页与布局
认证流程完成后,剩下的工作是把colors资源页做出来。先在 Supabase 的SQL Editor中打开 Quick start 里的Colors示例脚本并运行,它会创建一张colors表,作为应用读取的数据源。
数据展示页
在 src/pages/colors.tsx 中,用useListhook 从 Supabase 拉取颜色数据,并用 shadcn 的Table组件渲染:
import { useList } from "@refinedev/core"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { Skeleton } from "@/components/ui/skeleton"; type Color = { id: number; name: string | null; hex: string; red: number | null; green: number | null; blue: number | null; hue: number | null; sat_hsl: number | null; light_hsl: number | null; sat_hsv: number | null; val_hsv: number | null; source: string | null; }; const formatValue = (value: number | null, suffix = "") => { if (value === null) { return "-"; } return `${value}${suffix}`; }; const loadingRows = Array.from({ length: 5 }, (_, index) => index); export const Colors = () => { const { result, query } = useList<Color>({ resource: "colors", pagination: { mode: "off" }, sorters: [{ field: "id", order: "asc" }], }); const colors = result?.data ?? []; const isLoading = query.isLoading; return ( <div className="overflow-hidden rounded-xl border border-border/70 bg-card shadow-sm shadow-zinc-950/5 dark:shadow-black/20"> <Table> <TableHeader> <TableRow> <TableHead>Preview</TableHead> <TableHead>Name</TableHead> <TableHead>RGB</TableHead> <TableHead>HSL</TableHead> <TableHead>HSV</TableHead> <TableHead>Source</TableHead> </TableRow> </TableHeader> <TableBody> {isLoading ? loadingRows.map((row) => ( <TableRow key={`loading-${row}`}> <TableCell> <div className="flex items-center gap-3"> <Skeleton className="size-4 rounded-full" /> <Skeleton className="h-4 w-20" /> </div> </TableCell> <TableCell> <Skeleton className="h-4 w-24" /> </TableCell> <TableCell> <Skeleton className="h-4 w-24" /> </TableCell> <TableCell> <Skeleton className="h-4 w-24" /> </TableCell> <TableCell> <Skeleton className="h-4 w-24" /> </TableCell> <TableCell> <Skeleton className="h-4 w-16" /> </TableCell> </TableRow> )) : colors.map((color) => ( <TableRow key={color.id}> <TableCell> <div className="flex items-center gap-3"> <span aria-hidden="true" className="size-4 rounded-full border border-border shadow-sm" style={{ backgroundColor: color.hex }} /> <span className="font-mono text-xs uppercase text-muted-foreground"> {color.hex} </span> </div> </TableCell> <TableCell>{color.name ?? "Untitled"}</TableCell> <TableCell> {[ formatValue(color.red), formatValue(color.green), formatValue(color.blue), ].join(" / ")} </TableCell> <TableCell> {[ formatValue(color.hue), formatValue(color.sat_hsl, "%"), formatValue(color.light_hsl, "%"), ].join(" / ")} </TableCell> <TableCell> {[ formatValue(color.hue), formatValue(color.sat_hsv, "%"), formatValue(color.val_hsv, "%"), ].join(" / ")} </TableCell> <TableCell>{color.source ?? "-"}</TableCell> </TableRow> ))} </TableBody> </Table> </div> ); };这段代码演示了 Refine v5 风格的数据消费方式:useList返回{ result, query },其中result.data是经过规范化后的数据数组,query暴露 TanStack Query 层面的isLoading等状态;pagination: { mode: "off" }表示关闭服务端分页一次取全量数据,sorters按id升序排列。上述选项会原样传递到 data provider 的getList实现,最终映射为对 Supabase 的select()、order()查询。
布局与登出按钮
接着创建 src/pages/layout.tsx,提供一个带登出按钮的顶部应用栏:
import { type LayoutProps, useLogout } from "@refinedev/core"; import { Button } from "@/components/ui/button"; export const Layout: React.FC<LayoutProps> = ({ children }) => { const { mutate: logout, isPending } = useLogout(); return ( <div className="min-h-screen bg-background text-foreground"> <header className="sticky top-0 z-10 border-b border-border/60 bg-background/90 backdrop-blur"> <div className="mx-auto flex max-w-5xl items-center justify-between px-4 py-3"> <div> <h1 className="text-sm font-semibold tracking-tight">Colors</h1> <p className="text-xs text-muted-foreground"> Browse the color records fetched from Supabase. </p> </div> <Button variant="outline" size="sm" disabled={isPending} onClick={() => logout()} > Logout </Button> </div> </header> <main className="mx-auto w-full max-w-5xl px-4 py-6">{children}</main> </div> ); };useLogout内部调用authProvider.logout(即supabase.auth.signOut()),登出成功后 Refine 会把用户重定向回/login。
最后,把<Colors />与<Layout />引入 src/App.tsx,并在<Refine />中通过resources声明colors资源。完成后即可启动应用,完整跑通"输入手机号 → 接收短信验证码 → 输入 OTP → 登录并浏览 colors 表格"的闭环。
小结
OTP 认证为应用增加了一层不依赖密码的验证机制,能有效降低密码泄露与撞库风险。本文完整走通了以下链路:
- 使用
create refine-app创建以 Supabase 为后端、shadcn/ui 为 UI 的 Refine 应用; - 在 Supabase 控制台启用 Phone Auth 并接入 Twilio Verify(Account SID、Auth Token、Verify Service SID);
- 通过
supabaseClient.auth.signInWithOtp({ phone })发送验证码,通过verifyOtp({ phone, token, type: "sms" })校验验证码; - 编写定制化的
authProvider(login、logout、check、getIdentity、onError),配合useLogin/useLogouthooks 组织登录登出; - 用
Authenticated、CatchAllNavigate、NavigateToResource搭建受保护的路由树; - 借助 Refine 内置的 Supabase data provider 与
useList渲染colors资源。
仓库中完整可运行的示例位于 examples/blog-refine-supabase-auth,相关的 data provider / live provider 实现位于 packages/supabase。如果你还想探索 Refine 与 Supabase 的更多组合方式,可以参考仓库中另一篇 基于 Refine 与 Supabase 构建可定制问题追踪器 的实战指南。
【免费下载链接】refineA React Framework for building internal tools, admin panels, dashboards & B2B apps with unmatched flexibility.项目地址: https://gitcode.com/GitHub_Trending/re/refine
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考