ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Next.js + Supabase + Drizzle 全栈后台开发实战:从零搭建单词管理后台

2026/9/16 1:05:25 拓冰建站 浏览量
Next.js + Supabase + Drizzle 全栈后台开发实战:从零搭建单词管理后台 第一次听到Next.js Supabase Drizzle这套组合时我第一反应是Supabase 不是自带数据库 API 吗直接用它的 JavaScript 客户端查表就行为什么还要再套一层 Drizzle这个疑问一直到我做完单词大师后台才真正解开——三个工具各管一段不仅不重复配合起来反而比单用任何一个都顺手。这篇文章完整记录我从空项目开始搭建一个背单词管理后台的过程。后台要管三类东西单词、词库、学习记录。技术栈就按标题说的Next.js 承担页面和服务端逻辑Supabase 提供 PostgreSQL 和登录认证Drizzle 负责类型安全地读写数据库。文章会覆盖技术选型的原因、项目初始化、数据模型设计、完整的 CRUD 实现、管理员登录以及最后部署到 Vercel 时踩过的坑。如果你正准备做一个个人全栈项目或者想把现成的后台从能用升级到好维护这篇文章应该能帮你省掉不少试错时间。1. 为什么是 Next.js Supabase Drizzle 而不是其他组合先说结论这套组合不是三个技术硬凑在一起而是三层职责的合理切分。很多人在选型阶段卡住就是因为没搞清每一层到底在解决什么问题。1.1 三者各自的职责边界Next.js 在这套方案里扮演的是协调者。它负责页面渲染、路由、表单提交这些和 UI 相关的事情同时利用 App Router 的服务端组件特性在服务端直接完成数据库查询和业务逻辑不需要再单独起一个 Express 或 NestJS 服务。对单词大师这种内部后台来说少一个服务就少一层部署和鉴权。Supabase 提供的是基础设施。它本质上是一个开源 Firebase 替代品核心是一套托管好的 PostgreSQL 数据库同时附带 Auth、Storage、Edge Functions、自动生成 REST API 这些能力。选它最重要的理由是省心数据库备份、连接池、SSL、账号系统都不用自己运维。Drizzle 做的是数据访问层。它是一个 TypeScript 原生的轻量 ORM语法风格非常接近手写 SQL同时能提供从数据库字段到 TypeScript 类型的完整推导。它管三件事类型安全的查询构建、schema 定义和迁移管理、事务执行。用一个不太严谨但好记的类比Next.js 是餐厅的运营者Supabase 是物业和水电供应商Drizzle 是后厨里那套精确到克重的记账系统。没有 Drizzle账也能记但遇到批量采购、菜品调价、库存结算这些复杂流程时你会很想有一本正经的账本。1.2 为什么坚持再加一层 Drizzle很多人会问Supabase 控制台里不是能直接生成 REST API 吗前端用supabase-js查表不就行了为什么还要用 Drizzle这是我在实际项目里反复权衡过的问题结论是自动 API 适合客户端直连数据库的场景但不适合后台管理系统这种业务逻辑较重的场景。Supabase 的自动 API 依赖 RLSRow Level Security来控制数据可见性。意思是每个客户端请求都带着用户身份PostgreSQL 根据预设策略决定能不能读写某一行。这套机制在移动端或 C 端产品里很实用但放到后台管理页会非常别扭。比如管理员能修改所有词库普通用户只能看自己创建的词库这种规则写在 RLS 策略里不仅冗长调试起来还容易漏。更清晰的做法是把业务逻辑收回到服务端。在 Next.js 的 Server Action 或 API Route 里用 Drizzle 操作数据库普通用户永远不会直接拿到数据库表访问权限。服务端代码运行在可信环境里认证逻辑可以简化为管理员账号登录后操作安全模型容易理解得多。另一个关键理由是事务能力。批量导入单词时我希望要么全部成功、要么全部回滚这需要数据库事务。Supabase 自动 API 处理事务非常痛苦而 Drizzle 直接封装了db.transaction()和写原生 SQL 一样自然。第三个理由藏在维护成本里。Drizzle 的 schema 就是 TypeScript 代码字段改名、表结构调整时编译器会在所有用到的地方报错从数据库到页面链路完全贯通。自动 API 生成的类型是宽泛的 JSON改个字段名只能在运行时发现问题。1.3 与常见替代方案的对比顺便聊聊我对比过的其他方案方便你根据自己项目情况做判断技术组合适合场景主要问题Next.js Prisma 普通 PostgresCRUD 为主、团队熟悉 PrismaPrisma 生成层较重复杂查询类型推导有损耗Next.js Supabase 自动 API原型验证、移动端直连复杂权限和写事务绕业务逻辑容易散落客户端自建后端 手写 ORM对服务端控制力要求极高要自己管数据库、认证、备份个人项目负担大Next.js Supabase Drizzle个人全栈、中小后台、想长期维护Drizzle 生态相对 Prisma 年轻部分能力需自己封装为什么不干脆手写 SQL不是不行Drizzle 本身也是 SQL 的薄封装。直接写 SQL 的麻烦在于查出来的结果在 TypeScript 里拿不到自动类型表结构变更后容易漏改代码。Drizzle 保留了 SQL 的表达能力和灵活性同时把类型这件事补上了。2. 项目初始化到连上数据库环境配置全流程选型确定之后接下来就是动手把项目骨架搭起来把本地环境和远程 Supabase 数据库接通。这一步看起来简单实际卡住不少人的往往是连接串、SSL、环境变量这些细节。2.1 创建 Next.js 项目并安装依赖先创建一个全新的 Next.js 项目npx create-next-applatest word-master-admin交互式参数我建议这样选TypeScript 选 YesESLint 选 YesTailwind CSS 选 Yes后台页面写样式快使用 App Router 选 YesTurbopack 按自己喜好即可。进入项目后安装核心依赖cd word-master-admin npm install drizzle-orm postgres supabase/supabase-js npm install -D drizzle-kit dotenv tsx npm install zod这里说明一下依赖的职责。运行时依赖里drizzle-orm是 ORM 本体postgres是 postgres.js 驱动Drizzle 通过它连接 PostgreSQLsupabase/supabase-js主要用于 Auth登录认证因为这套方案里读写数据库走 Drizzle不需要 Supabase 自带的查询 API。开发依赖里drizzle-kit用来生成和管理迁移文件dotenv让 drizzle-kit 能读到.env.local里的配置tsx用来跑 TypeScript 脚本。zod是表单校验工具后面做 CRUD 时会用到。为什么用 App Router 而不是 Pages Router因为 App Router 的 Server Components 和服务端函数天然适合页面直接在服务端查数据库的写法整个后台不需要额外维护一套前端 API 请求层。2.2 创建 Supabase 项目与连接信息整理去 Supabase Dashboard 新建一个项目填写项目名和数据库密码。Region 选择上如果你的部署目标是 Vercel建议挑离 Vercel 函数区域近的节点能减少跨洋访问数据库的延迟。项目创建完成后需要拿到三类信息连接串进入 Project Settings - Database找到 Connection string。Supabase 给两种端口 5432 的直连串和端口 6543 的池化串。Drizzle 迁移和本地开发建议用 5432 直连串等上生产环境再考虑是否切换到池化连接。Project URLSettings - API 页面顶部的 URL形如https://xxxx.supabase.co。API Keys同页面下的anon publickey 和service_rolekey。anonkey 可以在客户端使用service_rolekey 能绕过所有 RLS 策略只能放在服务端绝对不要打进前端包。在项目根目录创建.env.localDATABASE_URLpostgresql://postgres:your_passworddb.xxxx.supabase.co:5432/postgres NEXT_PUBLIC_SUPABASE_URLhttps://xxxx.supabase.co NEXT_PUBLIC_SUPABASE_ANON_KEYyour_anon_key SUPABASE_SERVICE_ROLE_KEYyour_service_role_key记得把.env.local加进.gitignore这个文件一旦提交到公开仓库数据库就等于裸奔了。2.3 第一个 Drizzle 查询验证整条链路数据库连串拿到了接下来创建 Drizzle 客户端。新建src/db/index.tsimport { drizzle } from drizzle-orm/postgres-js; import postgres from postgres; const client postgres(process.env.DATABASE_URL!, { ssl: require, max: 5, }); export const db drizzle(client);这里有两个细节值得注意。第一ssl: require一定不能漏。Supabase 默认要求连接走 SSL如果漏掉postgres.js 会报类似no pg_hba.conf entry的错误。第二max: 5限制了连接池大小避免 Serverless 环境下冷启动时瞬间打满数据库连接。修改src/app/page.tsx先做一次最简单的查询验证链路import { db } from /db; import { sql } from drizzle-orm; export default async function Home() { const result await db.execute(sqlselect 1 as ok); return pre{JSON.stringify(result)}/pre; }执行npm run dev浏览器访问首页能看到[{ok:1}]就说明本地代码已经成功连通远程 Supabase 数据库了。到这里整条链路是通的接下来开始设计数据模型。3. 数据模型设计为单词大师后台建三张核心表数据库表结构是整个项目的底盘。我的经验是第一版表结构不要追求大而全够用、能扩展、关系清晰才是重点。单词大师后台第一版只需要三张表词库表、单词表、学习记录表。3.1 单词表与词库表业务主表的关系先看业务需求。后台管理员会维护多个词库比如CET-4 高频词、考研核心词每个词库里包含大量单词。单词和词库是典型的一对多关系一个词库对应多个单词。词库表wordbooks字段类型说明iduuid主键默认随机生成nametext词库名称非空descriptiontext词库简介is_publicboolean是否公开默认 truecreated_byuuid创建者用户 IDcreated_attimestamp创建时间单词表words字段类型说明iduuid主键termtext单词本身唯一约束phonetictext音标definitiontext释义非空exampletext例句example_translationtext例句翻译difficultyinteger难度 1-5默认 1wordbook_iduuid外键指向词库表created_attimestamp创建时间为什么主键都用 uuid 而不是自增 id因为后续可能要支持 CSV 批量导入或者多端离线合并数据自增 id 在合并时容易冲突uuid 随机生成则天然适合分布式场景。代价是索引稍微大一点后台这种数据量完全不用在意。外键约束上wordbook_id我设置了onDelete: set null意思是词库被删除后单词保留只是不再归属任何词库。这是刻意的单词本身是有价值的资产不能因为词库删了就一起消失。3.2 学习记录表为后续统计留好口子第三张表study_records一开始可能用不上但它决定了后台能不能展示用户学习进度这类统计信息。在设计单词表时就把学习记录表留好后面做间隔重复算法、掌握率报表时不用回头改表结构。字段类型说明iduuid主键word_iduuid外键指向单词表user_iduuid用户 IDstatustext状态new / learning / reviewing / masteredreview_countinteger复习次数error_countinteger错误次数last_reviewed_attimestamp上次复习时间next_review_attimestamp下次复习时间status四个状态对应背单词的典型生命周期新词new、学习中learning、复习中reviewing、已掌握mastered。后端在做每日任务查询时直接where(status reviewing and next_review_at now())就能捞出该复习的单词。word_id的外键我设置了onDelete: cascade和单词表那条set null正好相反。单词被删除时对应的学习记录没有保留价值应该跟着清掉避免留下大量孤儿数据。两种外键策略的差异体现了数据有没有独立价值的判断。3.3 用 Drizzle 写 schema 并生成迁移脚本现在用 Drizzle 定义这三张表。创建src/db/schema.tsimport { pgTable, uuid, text, integer, timestamp, boolean, } from drizzle-orm/pg-core; export const wordbooks pgTable(wordbooks, { id: uuid(id).defaultRandom().primaryKey(), name: text(name).notNull(), description: text(description), isPublic: boolean(is_public).default(true).notNull(), createdBy: uuid(created_by), createdAt: timestamp(created_at).defaultNow().notNull(), }); export const words pgTable(words, { id: uuid(id).defaultRandom().primaryKey(), term: text(term).notNull().unique(), phonetic: text(phonetic), definition: text(definition).notNull(), example: text(example), exampleTranslation: text(example_translation), difficulty: integer(difficulty).default(1).notNull(), wordbookId: uuid(wordbook_id).references(() wordbooks.id, { onDelete: set null, }), createdAt: timestamp(created_at).defaultNow().notNull(), }); export const studyRecords pgTable(study_records, { id: uuid(id).defaultRandom().primaryKey(), wordId: uuid(word_id) .notNull() .references(() words.id, { onDelete: cascade }), userId: uuid(user_id).notNull(), status: text(status, { enum: [new, learning, reviewing, mastered], }) .default(new) .notNull(), reviewCount: integer(review_count).default(0).notNull(), errorCount: integer(error_count).default(0).notNull(), lastReviewedAt: timestamp(last_reviewed_at), nextReviewAt: timestamp(next_review_at), });注意status字段我用了text加enum数组而不是 PostgreSQL 的pgEnum。原因很实际pgEnum强类型确实更规范但以后改枚举值需要执行ALTER TYPE迁移过程更重。后台项目里用 TypeScript 的类型约束代替数据库级枚举足够安全也省了很多迁移的麻烦。然后在根目录创建drizzle.config.tsimport { config } from dotenv; import { defineConfig } from drizzle-kit; config({ path: .env.local }); export default defineConfig({ schema: ./src/db/schema.ts, out: ./drizzle, dialect: postgresql, dbCredentials: { url: process.env.DATABASE_URL!, }, });这里dotenv的config({ path: .env.local })是必须的因为 drizzle-kit 默认只读.env而 Next.js 的约定是.env.local不指定路径它读不到连接串。生成并执行迁移npx drizzle-kit generate npx drizzle-kit migrategenerate会比较 schema 和数据库现状生成 SQL 迁移文件到drizzle/目录migrate才真正把 SQL 语句推进数据库。跑完后可以去 Supabase 控制台的 Table Editor 里看下三张表应该已经出现了。开发迭代阶段也可以直接npx drizzle-kit push把 schema 推到数据库但多人协作或生产环境升级还是走迁移文件更稳妥。4. 后台 CRUD用 Server Actions 实现单词管理数据表建好了接下来实现后台最核心的功能单词的增删改查。Next.js App Router 下的 Server Actions让这块代码量比传统前端调 API的模式少了很多逻辑也更集中。4.1 列表页服务端组件直接查库创建src/app/admin/words/page.tsx用 Server Component 直接查数据库import { db } from /db; import { words, wordbooks } from /db/schema; import { desc, eq } from drizzle-orm; export const dynamic force-dynamic; export default async function AdminWordsPage() { const list await db .select({ id: words.id, term: words.term, definition: words.definition, difficulty: words.difficulty, wordbookName: wordbooks.name, }) .from(words) .leftJoin(wordbooks, eq(words.wordbookId, wordbooks.id)) .orderBy(desc(words.createdAt)); return ( div classNamep-8 h1 classNametext-2xl font-bold mb-6单词管理/h1 table classNamew-full border-collapse thead tr th单词/th th释义/th th难度/th th所属词库/th /tr /thead tbody {list.map((w) ( tr key{w.id} td{w.term}/td td{w.definition}/td td{w.difficulty}/td td{w.wordbookName ?? -}/td /tr ))} /tbody /table /div ); }第一行export const dynamic force-dynamic很关键。如果不写Next.js 可能把页面静态化构建时查一次库后就缓存住了之后新增的单词不会出现在列表里。后台页面本身对首屏性能不敏感强制动态渲染更符合直觉。查询里用leftJoin关联词库表因为wordbook_id允许为空。如果单词不属于任何词库wordbookName会是null所以显示时兜底成-。字段只 select 了需要的列没有select *这也算是一个好习惯——减少数据传输量也让编译器知道页面到底用了哪些字段。4.2 新增与编辑表单绑定 Zod 校验传统后台开发要先写一个 POST 接口再在前端用 fetch 提交再把错误回传页面。Server Actions 把这些步骤合并成了一步表单直接调用服务端函数Next.js 负责序列化和重新渲染。创建src/app/admin/words/actions.tsuse server; import { z } from zod; import { db } from /db; import { words } from /db/schema; import { eq } from drizzle-orm; import { revalidatePath } from next/cache; import { redirect } from next/navigation; const wordSchema z.object({ term: z.string().min(1, 请输入单词), definition: z.string().min(1, 请输入释义), phonetic: z.string().optional(), example: z.string().optional(), exampleTranslation: z.string().optional(), difficulty: z.coerce.number().int().min(1).max(5), wordbookId: z.preprocess( (v) (v ? undefined : v), z.string().uuid().optional() ), }); export type WordFormState { error?: { form?: string[]; } Recordstring, string[] | undefined; }; export async function createWord( _prevState: WordFormState, formData: FormData ): PromiseWordFormState { const parsed wordSchema.safeParse(Object.fromEntries(formData)); if (!parsed.success) { return { error: parsed.error.flatten().fieldErrors }; } try { await db.insert(words).values({ ...parsed.data, }); } catch (e) { return { error: { form: [保存失败请检查单词是否重复] } }; } revalidatePath(/admin/words); redirect(/admin/words); } export async function updateWord( _prevState: WordFormState, formData: FormData ): PromiseWordFormState { const id formData.get(id) as string; const parsed wordSchema.safeParse(Object.fromEntries(formData)); if (!parsed.success) { return { error: parsed.error.flatten().fieldErrors }; } await db.update(words).set(parsed.data).where(eq(words.id, id)); revalidatePath(/admin/words); redirect(/admin/words); } export async function deleteWord(formData: FormData) { const id formData.get(id) as string; await db.delete(words).where(eq(words.id, id)); revalidatePath(/admin/words); }这段代码里有几个实战中容易踩的点逐个说下。use server在文件顶部表示这个文件里所有导出函数都是 Server Action只能在服务端运行不会打包进浏览器。z.coerce.number()专门处理表单传来的字符串。浏览器 FormData 里所有值都是 string如果直接z.number()校验前端传 3 会直接报错。用coerce后zod 会先把字符串转成数字再校验这个写法比在客户端手动Number()省事得多。wordSchema.safeParse(Object.fromEntries(formData))一次性把 FormData 转成对象再校验比一个个formData.get()干净。但有个坑空字符串字段在表单里会保留为比如wordbookId没选择时。所以用z.preprocess把空字符串转成undefined再走optional()避免把直接塞进 uuid 字段导致数据库报错。redirect一定要放在try/catch外面或者放在 catch 之后的逻辑里。因为redirect()内部会抛一个特殊异常如果被catch捕获页面就不会跳转了。这应该是我写 Server Actions 时踩过最隐蔽的坑。前端表单页用useActionState接收返回值use client; import { useActionState } from react; import { createWord, type WordFormState } from ./actions; const initialState: WordFormState {}; export function WordForm() { const [state, formAction, pending] useActionState( createWord, initialState ); return ( form action{formAction} classNamespace-y-4 div label htmlForterm单词/label input idterm nameterm classNameborder p-2 w-full placeholderapple / {state?.error?.term ( p classNametext-red-500 text-sm{state.error.term}/p )} /div div label htmlFordefinition释义/label textarea iddefinition namedefinition classNameborder p-2 w-full placeholdern. 苹果 / {state?.error?.definition ( p classNametext-red-500 text-sm{state.error.definition}/p )} /div div label htmlFordifficulty难度/label select iddifficulty namedifficulty classNameborder p-2 w-full option value11 - 简单/option option value22/option option value33/option option value44/option option value55 - 困难/option /select {state?.error?.difficulty ( p classNametext-red-500 text-sm{state.error.difficulty}/p )} /div button typesubmit disabled{pending} classNamebg-blue-600 text-white px-4 py-2 rounded disabled:opacity-50 {pending ? 保存中... : 保存单词} /button {state?.error?.form ( p classNametext-red-500 text-sm{state.error.form}/p )} /form ); }useActionState的第一个参数是 Server Action第二个参数是初始状态。它返回[state, formAction, pending]pending可以直接用来做按钮 loading不用自己维护请求状态。这是 React 19 之后的标准写法比早前的useFormStatus组合要简洁不少。4.3 删除与关联处理外键约束的取舍删除操作在列表页每个单词后面放一个表单按钮即可import { deleteWord } from ./actions; // 在每个表格行里 form action{deleteWord} input typehidden nameid value{w.id} / button typesubmit classNametext-red-600 删除 /button /form前面设计表结构时words.wordbook_id用了set nullstudy_records.word_id用了cascade。删除单词时学习记录会被自动清掉不会残留脏数据删除词库时单词会被置空wordbook_id但单词本身还在后续可以重新归类。这两种策略的差异本质上取决于被删数据的失去是否会影响其他数据的价值。实际用下来还会遇到一个业务问题单词表有unique约束重复插入同一个term会抛数据库异常。我在createWord的catch里统一返回了请检查单词是否重复的提示避免把数据库原始的英文报错直接甩给用户。5. 管理员登录用 Supabase Auth 护住后台后台系统最关键的安全防线就是登录。这里直接用 Supabase Auth省去自己维护密码哈希和 Session 的开销。5.1 配置 Auth 客户端与登录页先安装 SSR 支持的包npm install supabase/ssr在src/lib/supabase/server.ts创建服务端客户端import { createServerClient } from supabase/ssr; import { cookies } from next/headers; export async function createSupabaseServerClient() { const cookieStore await cookies(); return createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, { cookies: { getAll() { return cookieStore.getAll(); }, setAll(cookiesToSet) { try { cookiesToSet.forEach(({ name, value, options }) cookieStore.set(name, value, options) ); } catch {} }, }, } ); }注意cookies()在 Next.js 15 里返回的是 Promise需要await。setAll里的try/catch不是装饰是因为在 Server Component 里写 cookie 会被 React 阻止抛出的异常需要静默吞掉等路由刷新时再由 middleware 处理。浏览器端客户端更简单import { createBrowserClient } from supabase/ssr; export function createSupabaseBrowserClient() { return createBrowserClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! ); }登录页src/app/login/page.tsx是一个纯客户端组件提交时调用signInWithPassworduse client; import { useState } from react; import { useRouter } from next/navigation; import { createSupabaseBrowserClient } from /lib/supabase/browser; export default function LoginPage() { const router useRouter(); const [email, setEmail] useState(); const [password, setPassword] useState(); const [error, setError] useState(); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); const supabase createSupabaseBrowserClient(); const { error } await supabase.auth.signInWithPassword({ email, password, }); if (error) { setError(error.message); return; } router.push(/admin); router.refresh(); } return ( form onSubmit{handleSubmit} classNamemax-w-sm mx-auto mt-24 space-y-4 h1 classNametext-2xl font-bold管理员登录/h1 input typeemail value{email} onChange{(e) setEmail(e.target.value)} placeholderadminexample.com classNameborder p-2 w-full / input typepassword value{password} onChange{(e) setPassword(e.target.value)} placeholder密码 classNameborder p-2 w-full / {error p classNametext-red-500 text-sm{error}/p} button typesubmit classNamebg-blue-600 text-white px-4 py-2 rounded w-full 登录 /button /form ); }登录成功后要记着调router.refresh()它会重新触发服务端组件的 cookie 读取让登录状态立刻反映到页面上。只router.push不刷新的话可能还要手动刷新页面才能看到用户信息。去 Supabase Dashboard 的 Authentication - Sign In / Providers 里确认 Email 登录已开启然后在 URL Configuration 里把 Site URL 设置成你的生产域名否则部署后回调地址会不匹配。5.2 中间件拦截与 Server Action 双重校验middleware 负责入口拦截在用户进入/admin/*之前检查是否已经登录。在项目根目录创建middleware.tsimport { createServerClient } from supabase/ssr; import { NextResponse, type NextRequest } from next/server; export async function middleware(request: NextRequest) { let response NextResponse.next({ request: { headers: request.headers, }, }); const supabase createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, { cookies: { getAll() { return request.cookies.getAll(); }, setAll(cookiesToSet) { cookiesToSet.forEach(({ name, value }) request.cookies.set(name, value) ); response NextResponse.next({ request }); cookiesToSet.forEach(({ name, value, options }) response.cookies.set(name, value, options) ); }, }, } ); const { data: { user }, } await supabase.auth.getUser(); if (!user request.nextUrl.pathname.startsWith(/admin)) { const url request.nextUrl.clone(); url.pathname /login; url.searchParams.set(next, request.nextUrl.pathname); return NextResponse.redirect(url); } return response; } export const config { matcher: [/admin/:path*], };这里必须强调一个安全认知middleware 只是第一道门不能作为唯一校验。middleware 运行在 Edge 环境这里只能读 cookie 判断有没有 session不能访问数据库。如果攻击者绕过 middleware 直接构造请求调用 Server Action靠 middleware 是拦不住的。所以每个操作数据库的 Server Action 里都要再调一次requireAdminimport { createSupabaseServerClient } from /lib/supabase/server; export async function requireAdmin() { const supabase await createSupabaseServerClient(); const { data: { user }, } await supabase.auth.getUser(); if (!user) { throw new Error(未登录); } const email user.email ?? ; if (!email.endsWith(your-domain.com)) { throw new Error(无管理员权限); } return user; }然后在createWord、updateWord、deleteWord的开头直接调用export async function createWord(prevState, formData) { await requireAdmin(); // 其余逻辑... }这样即使 middleware 被绕过没有有效 session 的请求也会在这里被拦截。真正敏感的项目可以把管理员名单落一张admin_users表用 Drizzle 查表校验比邮箱后缀判断更标准。6. 部署上线的完整流程与碰过的坑后台功能都跑通了最后一步是部署上线。这里用的方案是 Vercel因为 Next.js 和 Vercel 的配合最省心Supabase 托管数据库也免去了自己维护服务器的麻烦。6.1 Vercel 部署与迁移执行顺序把代码推到 GitHub 后在 Vercel 里导入仓库Framework 会自动识别为 Next.js。然后在项目设置的环境变量页面把.env.local里的四个变量都加进去。特别注意 Vercel 的环境变量分 Development、Preview、Production 三档有些项目本地正常、部署后报错就是因为 Production 环境变量没配全。数据库迁移建议在部署前显式执行而不是依赖构建脚本。最稳妥的流程是本地先跑npx drizzle-kit migrate确认迁移已经推进生产数据库。再触发 Vercel 部署。有些教程会让你在package.json里加prebuild: drizzle-kit migrate这样每次构建前自动迁移。但我个人不推荐刚上手就这么做因为 Vercel 的构建服务器 IP 不稳定如果 Supabase 开了数据库 IP 白名单构建时迁移会直接失败连带部署也挂掉。把迁移和部署解耦排错时思路会清晰很多。部署成功后回到 Supabase Dashboard 把 Auth 的 Site URL 改成 Vercel 域名否则登录接口的回调地址会不匹配。6.2 三个值得记录的环境与连接问题这个项目从本地到生产我遇到了三个值得记录的问题写出来帮你提前避坑。问题一SSL 连接报错本地跑npm run dev页面报no pg_hba.conf entry for host xxx, user postgres, database postgres, no encryption。这个错误很直白postgres.js 连接时没有启用 SSL。解法就是src/db/index.ts里ssl: require。如果已经写了还报错检查DATABASE_URL是否从 Supabase 控制台完整复制我踩过一次连接串里丢了密码却没报明显错误的情况。问题二Vercel 部署后DATABASE_URLundefined本地一切正常部署到 Vercel 后构建报错说process.env.DATABASE_URL是空的。原因几乎肯定是 Vercel 的 Production 环境变量没配置或者配置的时候粘贴错了名字。建议在环境变量页面逐项核对配好后重新部署一次。问题三drizzle-kit migrate 连不上数据库npx drizzle-kit migrate报连接超时或者提示端口拒绝访问。这时候看看自己用的是不是6543端口。Supabase 的连接池地址走的是 6543它需要额外的池化参数而 drizzle-kit 迁移建议用5432直连地址。如果项目用了连接池migrate 失败时先切回直连串试试。还有一个免费版特有的问题Supabase 项目如果一段时间没有请求会进入暂停状态下次访问时第一次请求会特别慢甚至超时。开发调试时如果发现突然连不上先去 Dashboard 看看项目状态点一下恢复再试。如果是生产环境这个不稳定因素需要考虑升级或者换自建数据库方案。最后再分享一点实际体感这个后台从选型到上线整体给我的感受是Next.js 把服务端和页面的边界踩平了Supabase 把基础设施的脏活累活外包了Drizzle 让写数据库的时候类型全程在线。以前做一个后台要同时维护前端工程、服务端接口、数据库脚本、登录体系四套东西现在一个 Next.js 项目里就能全部收拢迭代效率明显高了一截。如果你跟我一样是首个全栈项目我建议先不要追求把所有功能都做完把 CRUD 跑通、登录守住、部署上线这三个里程碑完成后再回来看你对全栈的理解会比读十篇教程都深。下一步我打算给单词大师加一个 CSV 批量导入词库的功能顺便试试用 Drizzle 的事务保证导入过程中不会产生半截数据等做完再写一篇实战记录。