你好!从 Java (Spring Boot + MyBatis-Plus) 转向 Next.js + Prisma 是一个非常丝滑的体验。你会发现,虽然语言变了,但很多架构思想是相通的。
作为 Java 后端出身,你习惯了 Entity、Service、Mapper 和 Strong Typing。Prisma 的核心优势正是类型安全,这会让你感觉像回到了家。
以下是我为你定制的 “从 MyBatis-Plus 到 Prisma” 快速上手计划:
核心概念映射(帮你在脑中建立桥梁)
在开始动手前,先理解这两个世界的对应关系:
| 功能 | Spring Boot + MyBatis-Plus | Next.js + Prisma |
|---|---|---|
| 定义表结构 | 数据库 DDL 或 @TableName 实体类 | schema.prisma 文件 |
| 代码生成 | MyBatis-Plus Generator | npx prisma generate |
| CRUD 操作 | IService / BaseMapper | prisma.user.findMany() / create() |
| 关联查询 | XML 联表或 @TableField(exist=false) | 核心优势:Schema 定义 Relation,直接 include |
| 数据库迁移 | Flyway / Liquibase | npx prisma migrate dev |
学习计划:三步走策略
既然数据库已经在 Supabase 选好了地区并创建完成,现在的任务是让你的 Next.js 项目能和它“说上话”。
我们将使用 Prisma。它是目前 React/Next.js 生态中最强大的 ORM(对象关系映射),能让你像操作 JavaScript 对象一样操作数据库,而不需要写一行 SQL。
1. 安装 Prisma 依赖
在你的项目根目录下运行:
npm install prisma --save-dev
npm install @prisma/client
然后初始化 Prisma 配置:
npx prisma init
2. 配置环境变量
初始化后,你会发现根目录多了一个 .env 文件。
打开它,把你在 Supabase 拿到的 Connection String 填进去:
# .env
# 记得把 [YOUR-PASSWORD] 换成你在 Supabase 设置的项目密码
DATABASE_URL="postgresql://postgres:[YOUR-PASSWORD]@db.xxxx.supabase.co:5432/postgres"
注意:如果你的连接字符串后面没有
?pgbouncer=true且你在使用云端环境,建议在 Supabase 的连接设置里选择 Transaction 模式 的端口(通常是 6543)。
3. 设计数据模型 (Schema)
打开 prisma/schema.prisma 文件。我们要定义一个简单的“博客文章”模型。
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
}
// 定义我们的文章表
model Post {
id String @id @default(cuid()) // 自动生成唯一 ID
title String
content String?
published Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
4. 同步到数据库 (Migration)
1. 这一步最关键。它会做两件事:
- 在 Supabase 的数据库里真实地创建那张
Post表。 - 在本地生成 TypeScript 类型定义(这样你写代码时就有自动补全了)。
运行:
npx prisma migrate dev --name init
Java 视角:这相当于你写好了 Entity,它自动帮你生成了 DDL 脚本并推送到数据库执行,而且还帮你记录了版本历史。
2. 它是你的“代码生成器”(类似 MyBatis-Plus Generator)
这是 Prisma 最强大的地方。当 SQL 执行完毕后,它会紧接着触发 prisma generate。
- 它会根据最新的数据库结构,在
node_modules/.prisma/client中生成一套全类型的 SDK。 - 效果:你在写 JS/TS 代码时,调用
prisma.user.findUnique(...)会有极其精准的字段提示。如果数据库里没有age这个字段,你代码里写age直接会报红。
3. 命令参数拆解
migrate: 数据库迁移指令。dev: 表示这是开发环境模式。它会检测schema.prisma的变化,并允许在必要时重置数据库。--name init: 给这次改动起个名字。生成的文件夹会叫20260206_init这种格式,方便你追溯“哪次改动增加了哪个字段”。
避坑小贴士
- 不要手动去数据库改表:如果你用 Navicat 手动改了字段,但没有改
schema.prisma,下次 migrate 时 Prisma 可能会因为检测到冲突而要求重置数据库(导致数据丢失)。永远坚持“Schema 优先”原则。 - 生产环境:在生产环境中,千万不要用
migrate dev,而是使用npx prisma migrate deploy。它只会执行现有的 SQL 脚本,不会尝试检测变化或重置数据。
5. 在项目里实例化 Prisma
为了不在每次请求时都创建一个新的数据库连接,我们需要一个单例模式。
在 Prisma 7 中,官方对配置方式做了重大调整:不再建议在 schema.prisma 中写 url = env("DATABASE_URL"),而是改用 prisma.config.ts 进行配置。
1. 创建文件 lib/db.ts
我们需要从 @prisma/adapter-pg(或其他对应驱动)中引入适配器,并将其传递给 PrismaClient。但为了最简单地解决你现在的报错,我们需要在构造函数中明确传入连接配置。
由于你使用的是 PostgreSQL,我们需要安装并配置驱动。请先执行以下命令安装适配器:
npm install @prisma/adapter-pg pg
npm install -D @types/pg
创建文件 lib/db.ts:
import { PrismaClient } from "@prisma/client";
import { Pool } from "pg";
import { PrismaPg } from "@prisma/adapter-pg";
const connectionString = `${process.env.DATABASE_URL}`;
const pool = new Pool({ connectionString });
const adapter = new PrismaPg(pool);
const globalForPrisma = global as unknown as { prisma: PrismaClient };
export const db =
globalForPrisma.prisma ||
new PrismaClient({
adapter, // Prisma 7 推荐使用适配器
});
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = db;
2. 还原 prisma/schema.prisma
既然 Prisma 7 不再支持在 schema 中写 url,我们保持它原来的简洁样子即可:
datasource db {
provider = "postgresql"
// 这里不需要写 url 了,Prisma 7 会从配置文件或构造函数中读取
}
为什么 Prisma 7 要这么改?
Prisma 7 引入了“驱动适配器(Driver Adapters)”的概念,目的是为了让 Prisma 能在更多环境下(如 Edge Functions, Cloudflare Workers)运行。
- 之前:Prisma 内部自带了复杂的 Rust 引擎来连接数据库。
- 现在:Prisma 建议你使用标准的 JavaScript 数据库驱动(如
pg),然后通过adapter传给 Prisma。
操作建议
- 运行上面的
npm install命令。 - 修改
lib/db.ts,像我上面展示的那样传入adapter。 - 运行
npx prisma generate。
如果你觉得这个过程对新手来说太复杂,你也可以尝试将 package.json 中的 Prisma 版本降级到 ^6.0.0,那样就可以使用传统的 url = env(...) 写法了。
Supabase CLI
1. 核心架构:多项目并行
你需要配置两个环境变量文件,并让你的自动化部署流程(如 Vercel, GitHub Actions)自动识别。
环境变量管理
.env.development:填入测试项目的 URL 和 Anon Key。.env.production:填入生产项目的 URL 和 Anon Key。
在前端初始化 Supabase 时,代码应该是动态的:
import { createClient } from '@supabase/supabase-js'
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
export const supabase = createClient(supabaseUrl, supabaseAnonKey)
2. 同步方案:不要手动点网页!
最忌讳的是在测试环境改了表结构(Schema),然后靠记忆去生产环境重复操作。这一定会导致线上报错。
推荐工具:Supabase CLI (迁移管理)
Supabase CLI 是同步两个线上环境的神器。
- 拉取测试环境的变更: 你在测试环境项目修改了数据库后,运行:
supabase db diff --project-ref <测试环境ID> -f feature_name
这会生成一个 .sql 文件,记录了所有变更。
2. 应用到生产环境:
supabase db push --project-ref <生产环境ID>
3. 分阶段部署流程 (Best Practice)
第一步:本地 Mock (Local)
在本地运行 supabase start(基于 Docker)。在这里进行所有“毁灭性”的尝试,完全免费。
第二步:线上测试 (Staging/Dev)
当你本地代码写好了,提交到 Git 的 develop 分支。
- 自动化部署:配置 CI/CD 自动将
develop分支的代码部署到你的测试域名(如dev.myapp.com)。 - 数据库迁移:运行
supabase db push到测试环境。在这个环境进行真人测试或接口联调。
第三步:线上生产 (Production)
测试通过后,将代码合并到 main 分支。
- 生产部署:代码同步到生产域名。
- 数据同步:执行迁移 SQL 到生产项目。
4. 特别注意:两个环境的“差异化”
| 功能 | 测试环境配置 | 生产环境配置 |
|---|---|---|
| Auth | 开启允许未验证邮箱登录(方便测试) | 必须验证邮箱,开启 MFA |
| 使用测试发信配置或直接看日志 | 配置 SendGrid/Resend 真实发信 | |
| Storage | 随便上传,定期清理 | 开启 CDN,设置严格的 RLS 策略 |
| Webhooks | 指向测试版的测试 API | 指向生产版的生产 API |
CRUD 测试
查
现在回到你的 app/page.tsx,我们尝试从数据库里读出文章列表。
// app/page.tsx
import { db } from "@/lib/db";
import { Button } from "@/components/ui/button";
export default async function Home() {
// 直接查询数据库!
const posts = await db.post.findMany();
return (
<main className="p-4">
<h1 className="text-2xl font-bold mb-4">文章列表</h1>
{posts.length === 0 ? (
<p className="text-gray-500">暂时没有文章</p>
) : (
<ul className="space-y-2">
{posts.map((post) => (
<li key={post.id} className="p-4 border rounded-lg shadow-sm">
{post.title}
</li>
))}
</ul>
)}
</main>
);
}
增
既然你的数据库和 Prisma 已经就绪,现在我们就来完成 CRUD 中的 C (Create)。
在 Next.js 中,最推荐的做法是使用 Server Actions。它能让你在前端表单中直接调用后端的异步函数,且无需手动编写 API 接口。
第一步:创建 Server Action
在 app 目录下建议新建一个 actions 文件夹(或者直接在 lib 下),创建 post-actions.ts。
// app/actions/post-actions.ts
"use server"; // 必须在文件顶部,声明这里全是服务端逻辑
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
// 1. 定义校验规则 (Schema)
const PostSchema = z.object({
title: z.string().min(2, "标题至少2个字符"),
content: z.string().min(5, "内容太短了"),
});
export async function createPost(prevState: any, formData: FormData) {
// 2. 从表单提取并校验数据
const validatedFields = PostSchema.safeParse({
title: formData.get("title"),
content: formData.get("content"),
});
if (!validatedFields.success) {
// 返回具体的字段级错误信息supabase db
return {
success: false,
errors: validatedFields.error.flatten().fieldErrors,
};
}
// 3. 写入数据库
await db.post.create({
data: validatedFields.data,
});
// 4. 【关键】告诉 Next.js 列表页缓存过期了,需要重新拉取
revalidatePath("/");
// 5. 跳转回首页
redirect("/");
}
第二步:构建 shadcn 表单界面
我们将创建一个新页面 /posts/new 来放置表单。你可以直接使用原生的 form 标签配合 shadcn 的样式。
// app/posts/new/page.tsx
"use client";
import { useActionState } from "react";
import { createPost } from "../../actions/post-actions";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
export default function NewPostPage() {
// state: Action 返回的对象 (初始值为第二个参数)
// formAction: 绑定到 form 标签的新函数
// isPending: 是否正在提交
const [state, formAction, isPending] = useActionState(createPost, {
success: false,
errors: {},
});
return (
<div className="max-w-md mx-auto mt-10">
<h1 className="text-2xl font-bold mb-6">发布新文章</h1>
{/* 直接将 Server Action 传给 action 属性 */}
<form action={formAction} className="space-y-4">
<div>
<label className="text-sm font-medium">标题</label>
<Input name="title" placeholder="输入文章标题..." />
</div>
<div>
<label className="text-sm font-medium">内容</label>
<Textarea name="content" placeholder="写点什么吧..." rows={5} />
</div>
{state?.errors && <p className="text-red-500">{state.errors.content}</p>}
{state?.success && <p className="text-green-500">{state.success}</p>}
<Button disabled={isPending} type="submit" className="w-full">提交发布</Button>
</form>
</div>
);
}
第三步:原理剖析 (Vue 开发者视角)
revalidatePath("/"):这是 Next.js 的灵魂。Vue 项目通常需要手动再次fetch列表,而这里你只需告诉 Next.js:“首页的数据旧了,请刷新”,它会自动处理。- 安全性:由于
createPost是async且标记了'use server',代码逻辑永远不会被下载到浏览器,数据库凭证非常安全。
改
删
在完成“新增”后,挑战“删除”功能是理解 Next.js 全栈数据流的关键。
在 Vue 中,删除通常是 axios.delete('/api/post/123')。在 Next.js Server Actions 中,挑战点在于:如何把 ID 传给服务器函数?
1. 编写删除 Action
在你的 app/actions/post-actions.ts 中添加删除逻辑:
// app/actions/post-actions.ts
'use server'
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";
export async function deletePost(id: string) {
// 直接调用 Prisma 删除
await db.post.delete({
where: { id }
});
// 告诉 Next.js 首页数据变了,请刷新
revalidatePath("/");
}
2. 在列表页实现删除按钮
这里有两种主流传参方式,推荐第一种(最符合 React 习惯):
方案 A:使用 .bind() 传参 (推荐)
bind 可以预设函数的参数,这是 Next.js 官方推荐的向 Server Action 传递额外数据(如 ID)的方式。
// app/page.tsx
import { db } from "@/lib/db";
import { deletePost } from "@/app/actions/post-actions";
import { Button } from "@/components/ui/button";
export default async function HomePage() {
const posts = await db.post.findMany();
return (
<div className="space-y-4">
{posts.map((post) => {
// 使用 bind 预设 ID 参数
const deletePostWithId = deletePost.bind(null, post.id);
return (
<div key={post.id} className="flex justify-between border p-4 items-center">
<span>{post.title}</span>
{/* 用一个小的 form 包裹按钮 */}
<form action={deletePostWithId}>
<Button variant="destructive" type="submit">
删除
</Button>
</form>
</div>
);
})}
</div>
);
}
方案 B:使用隐藏域 (type="hidden")
这种方式更接近传统 HTML 表单。
<form action={deletePost}>
<input type="hidden" name="id" value={post.id} />
<Button type="submit">删除</Button>
</form>
// 对应的 Action 里需要用 formData.get("id") 获取
3. 为什么一定要用 <form> 包裹删除按钮?
作为 Vue 开发者可能会问:我能不能直接给 Button 绑定一个 onClick={deletePost(id)}?
- 原因:
deletePost是一个 Server Action(服务器函数)。在服务器组件(Server Components)里,是没有onClick这种浏览器事件的。 - 优势:使用
<form>的好处是 “渐进增强”。即使用户的浏览器由于网络原因还没加载完 JavaScript,点击这个按钮,删除功能依然有效!
4. 挑战升级:确认弹窗 (Dialog)
如果你想在删除前弹出一个“你确定吗?”的对话框,就需要用到 shadcn/ui 的 AlertDialog 组件。因为弹窗涉及浏览器交互,这时候就需要用到 'use client'。
这个挑战非常经典!它涉及到了 Next.js 开发中最高频的场景:如何在保持全栈能力的服务器组件中,加入需要交互的客户端 UI?
为了实现带确认弹窗的删除,我们需要把“删除按钮”抽离成一个 Client Component。
1. 安装 shadcn 弹窗组件
首先,安装 shadcn 提供的 AlertDialog:
npx shadcn@latest add alert-dialog
2. 创建客户端删除组件
在 components/DeleteButton.tsx 中编写代码。注意这里我们要用到 useTransition,它可以让我们在执行 Server Action 时展示加载状态。
'use client' // 必须是客户端组件
import { useTransition } from "react"
import { deletePost } from "@/app/actions/post-actions"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import { Loader2, Trash2 } from "lucide-react" // 需要安装 lucide-react
export function DeleteButton({ id }: { id: string }) {
const [isPending, startTransition] = useTransition()
return (
<AlertDialog>
{/* 1. 触发按钮 */}
<AlertDialogTrigger asChild>
<Button variant="outline" size="icon" className="text-destructive">
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
{/* 2. 弹窗内容 */}
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确定要删除吗?</AlertDialogTitle>
<AlertDialogDescription>
此操作无法撤销,这篇文章将从数据库中永久移除。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
{/* 3. 确认执行删除 */}
<AlertDialogAction
onClick={() => startTransition(() => deletePost(id))}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={isPending}
>
{isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
确定删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
3. 在列表页引用
回到你的 app/page.tsx(服务器组件),直接使用这个按钮:
// app/page.tsx
import { db } from "@/lib/db";
import { DeleteButton } from "@/components/DeleteButton";
export default async function HomePage() {
const posts = await db.post.findMany();
return (
<div className="grid gap-4">
{posts.map((post) => (
<div key={post.id} className="p-4 border rounded flex justify-between items-center">
<div>
<h3 className="font-bold">{post.title}</h3>
<p className="text-sm text-muted-foreground">{post.content?.substring(0, 30)}...</p>
</div>
{/* 使用我们封装的带弹窗的删除组件 */}
<DeleteButton id={post.id} />
</div>
))}
</div>
);
}
4. 关键点剖析(Vue 开发者必看)
-
混合模式 (Hybrid):
page.tsx是服务器组件,它负责读数据;DeleteButton是客户端组件,它负责交互。Next.js 支持这种无缝嵌套。 -
useTransition:- 在 Vue 中,你可能会定义一个
isLoading响应式变量。 - 在 React/Next.js 中,
startTransition会自动处理异步操作。当deletePost(Server Action)在后台执行并调用revalidatePath时,页面会自动重新渲染,而这个过程中的状态都会被isPending捕获。
- 在 Vue 中,你可能会定义一个
-
参数传递:我们直接把
id作为 Props 传给客户端组件,然后由客户端组件在调用deletePost(id)时传回给服务器。
鉴权
在 Next.js 的全栈开发中,身份认证(Auth)曾是最让人头疼的部分,但现在业界已经有了非常成熟的方案。
对于 Next.js 项目,我强烈推荐你使用 Clerk。
- 为什么不选 Auth.js (NextAuth)? Auth.js 很强大,但配置极其繁琐(需要自己搞定数据库适配、邮件服务等)。
- 为什么选 Clerk? 它是目前的“版本答案”。它提供现成的登录/注册组件(带 UI),自带用户管理后台,且与 Next.js App Router 完美集成。5 分钟就能配置好。
1. 快速上手 Clerk
第一步:安装
npm install @clerk/nextjs
第二步:配置环境变量
去 Clerk 官网 创建一个项目,你会得到两个 Key,填入 .env.local:
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
CLERK_SECRET_KEY=sk_test_...
第三步:包裹你的应用
在根布局中加入 ClerkProvider。
// app/layout.tsx
import { ClerkProvider } from '@clerk/nextjs'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<ClerkProvider>
<html lang="en">
<body>{children}</body>
</html>
</ClerkProvider>
)
}
2. 保护你的路由 (Middleware)
你肯定不希望未登录用户能看到 /posts/new。Clerk 通过 middleware.ts 轻松实现这一点。
在根目录创建 middleware.ts,Next.js >15 后是proxy.ts:
// middleware.ts
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
// 定义哪些路径是“受保护”的
const isProtectedRoute = createRouteMatcher(['/posts/new(.*)', '/posts/(.*)/edit']);
export default clerkMiddleware(async (auth, req) => {
if (isProtectedRoute(req)) await auth.protect(); // 如果没登录,自动重定向到登录页
});
export const config = {
matcher: ['/((?!.*\\..*|_next).*)', '/', '/(api|trpc)(.*)'],
};
3. 在 UI 中显示用户信息
Clerk 提供了现成的 UI 组件,你可以直接放在你的 Sidebar 或 Navbar 里。
import { SignInButton, SignedIn, SignedOut, UserButton } from "@clerk/nextjs";
export default function Navbar() {
return (
<nav className="flex justify-between p-4 border-b">
<span>我的博客</span>
<div>
{/* 如果没登录,显示登录按钮 */}
<SignedOut>
<SignInButton mode="modal">
<button className="btn">登录</button>
</SignInButton>
</SignedOut>
{/* 如果已登录,显示用户头像/管理菜单 */}
<SignedIn>
<UserButton showName />
</SignedIn>
</div>
</nav>
);
}
4. 在后端 (Server Actions) 获取用户
这是最重要的一步:如何确定是谁在发帖?
// app/actions/post-actions.ts
import { auth } from "@clerk/nextjs/server";
export async function createPost(formData: FormData) {
'use server'
// 从 Clerk 获取当前用户 ID
const { userId } = await auth();
if (!userId) throw new Error("请先登录");
await db.post.create({
data: {
title: formData.get("title") as string,
content: formData.get("content") as string,
authorId: userId, // 记得在 Prisma Schema 里加上这个字段
},
});
}
5. 挑战进阶:关联用户与文章
为了让 CRUD 更完善,你需要修改你的 prisma/schema.prisma:
model Post {
id String @id @default(cuid())
title String
content String?
authorId String // 存储 Clerk 的用户 ID
// ...
}
修改 Schema 后记得运行 npx prisma migrate dev。
6. 修改登录表单样式
要让 Clerk 的登录面板看起来完全像 shadcn/ui 的组件,最优雅的方式是利用 Clerk 的 appearance 属性,将 Tailwind CSS 类名和 shadcn 的全局变量注入进去。
这样你既能享受 Clerk 自动处理验证码、找回密码等复杂逻辑的便利,又能获得 100% 匹配的视觉风格。
方案:全局统一风格 (推荐)
你可以在 app/layout.tsx 的 ClerkProvider 中进行全局配置,这样所有的登录、注册、个人设置组件都会自动应用风格。
// app/layout.tsx
import { ClerkProvider } from "@clerk/nextjs";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
// app/layout.tsx
<ClerkProvider
appearance={{
variables: {
colorPrimary: 'oklch(0.852 0.199 91.936)', // 对应 shadcn 的 --primary
colorBackground: 'oklch(1 0 0)', // 对应 --background
colorText: 'oklch(0.145 0 0)', // 对应 --foreground
borderRadius: '0.625rem', // 对应 --radius
},
elements: {
card: 'shadow-md border border-border', // 登录卡片样式
headerTitle: 'text-2xl font-bold tracking-tight',
headerSubtitle: 'text-muted-foreground',
formButtonPrimary: 'bg-primary text-primary-foreground hover:bg-primary/90', // 主按钮
socialButtonsBlockButton: 'border-input hover:bg-accent hover:text-accent-foreground', // 社交登录按钮
footerActionLink: 'text-primary hover:text-primary/90', // 底部链接
}
}}
>
{/* ... */}
</ClerkProvider>
);
}
核心配置项说明
elements: 这里可以使用任意 Tailwind 类名。你可以直接去components/ui/button.tsx或input.tsx里复制 shadcn 的默认类名粘贴进来。formFieldInput: 这是修改输入框的关键。上面的代码模拟了 shadcnInput组件的边框、环绕阴影和聚焦效果。formButtonPrimary: 这是修改登录按钮的关键。它会将 Clerk 默认的蓝色按钮改为你在globals.css中定义的primary颜色。
进阶:如何适配深色模式?
如果你的项目支持深色模式,Clerk 提供了一个官方主题包,能更好地处理背景色切换。
- 安装主题包:
npm install @clerk/themes
- 在配置中引入:
import { dark } from "@clerk/themes";
<ClerkProvider
appearance={{
baseTheme: dark, // 如果用户系统是深色,Clerk 会自动调整基础色调
elements: { /* 你的自定义类名 */ }
}}
>
要让样式生效,你需要告诉 Clerk:“不要去你的官网登录,去我项目里自己写的 /sign-in 页面登录。”
- 宿主环境改变:之前是在 Clerk 官网登录(样式不受控);现在是在你的 Next.js 路由里登录。
- Context 注入:你在
layout.tsx里写的<ClerkProvider appearance={...}>会通过 React Context 将样式传递给所有后代组件。当路由跳转到/sign-in时,页面里的<SignIn />就能拿到你配置的那些oklch变量和 Tailwind 类名了。
7. 自定义登录表单
如果你觉得 Clerk 的默认卡片式 UI 限制太多(即使去掉了边框、阴影和标志依然不满意),2026 年最流行的做法是 Custom Flow(自定义流程)。
Clerk 提供了一系列 Headless Hooks(如 useSignIn, useSignUp),你可以用自己写的 HTML/Tailwind 输入框来构建登录界面,完全不使用 Clerk 的
这样你就能拥有 100% 自由度,没有任何 Clerk 的默认样式或标签。
1. 核心逻辑拆解
要同时支持 Google 登录 和 邮箱/密码登录,流程如下:
- Google: 调用
signIn.authenticateWithRedirect()。 - 邮箱密码: 调用
signIn.create()然后用signIn.attemptFirstFactor()处理。
2. 创建自定义登录页面
在 app 目录下创建目录 app/sign-in/[[...sign-in]]/page.tsx:
"use client";
import * as React from "react";
import { useSignIn } from "@clerk/nextjs";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { EyeIcon } from "lucide-react";
import Link from "next/link";
export default function CustomSignInPage() {
const { isLoaded, signIn, setActive } = useSignIn();
const [email, setEmail] = React.useState("");
const [password, setPassword] = React.useState("");
const router = useRouter();
if (!isLoaded) return null;
// --- 1. 处理谷歌登录 ---
const signInWithGoogle = () => {
return signIn.authenticateWithRedirect({
strategy: "oauth_google",
redirectUrl: "/sso-callback", // 登录成功后的中转页
redirectUrlComplete: "/", // 最终跳转页
});
};
// --- 2. 处理邮箱密码登录 ---
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
const result = await signIn.create({
identifier: email,
password,
});
if (result.status === "complete") {
await setActive({ session: result.createdSessionId });
router.push("/");
} else {
console.log("还需要其他验证步骤(如 MFA):", result);
}
} catch (err: any) {
console.error("登录失败:", err.errors[0].message);
}
};
return (
<div className="min-h-screen w-full flex flex-col items-center justify-center bg-white px-4">
{/* Logo 部分 */}
<div className="mb-12">
<h1 className="text-xl font-bold tracking-tight font-mono">ElevenLabs</h1>
</div>
<div className="w-full max-w-[400px] space-y-6">
{/* 标题 */}
<div className="text-center mb-8">
<h2 className="text-2xl font-extrabold tracking-tight">
Welcome back
</h2>
</div>
{/* 第三方登录按钮组 */}
<div className="space-y-3">
<Button
variant="outline"
onClick={signInWithGoogle}
className="w-full h-12 rounded-xl cursor-pointer font-medium"
>
<svg width="20" height="20" viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg"><title>Google</title><g clip-path="url(#clip0_95_488)"><path d="M24.2663 12.7764C24.2663 11.9607 24.2001 11.1406 24.059 10.3381H12.7402V14.9591H19.222C18.953 16.4494 18.0888 17.7678 16.8233 18.6056V21.6039H20.6903C22.9611 19.5139 24.2663 16.4274 24.2663 12.7764Z" fill="#4285F4"></path><path d="M12.7401 24.5008C15.9766 24.5008 18.7059 23.4382 20.6945 21.6039L16.8276 18.6055C15.7517 19.3375 14.3627 19.752 12.7445 19.752C9.61388 19.752 6.95946 17.6399 6.00705 14.8003H2.0166V17.8912C4.05371 21.9434 8.2029 24.5008 12.7401 24.5008Z" fill="#34A853"></path><path d="M6.00277 14.8003C5.50011 13.3099 5.50011 11.6961 6.00277 10.2057V7.11481H2.01674C0.314734 10.5056 0.314734 14.5004 2.01674 17.8912L6.00277 14.8003Z" fill="#FBBC04"></path><path d="M12.7401 5.24966C14.4509 5.2232 16.1044 5.86697 17.3434 7.04867L20.7695 3.62262C18.6001 1.5855 15.7208 0.465534 12.7401 0.500809C8.2029 0.500809 4.05371 3.05822 2.0166 7.11481L6.00264 10.2058C6.95064 7.36173 9.60947 5.24966 12.7401 5.24966Z" fill="#EA4335"></path></g><defs><clipPath id="clip0_95_488"><rect width="24" height="24" fill="white" transform="translate(0.5 0.5)"></rect></clipPath></defs></svg>
Sign in with Google
</Button>
</div>
{/* 分割线 */}
<div className="relative py-4">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-gray-200" />
</div>
</div>
{/* 表单部分 */}
<form className="space-y-4" onSubmit={handleSubmit}>
<div className="space-y-2">
<Label htmlFor="email" className="text-sm font-blod">
Email
</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="h-12 rounded-xl"
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label
htmlFor="password"
className="text-sm font-medium text-gray-700"
>
Password
</Label>
<button className="text-xs text-gray-400 hover:underline">
Forgot your password?
</button>
</div>
<div className="relative">
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="h-12 rounded-xl"
/>
<button className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600">
<EyeIcon size={18} />
</button>
</div>
</div>
<Button className="w-full h-12 font-bold rounded-xl">
Sign in
</Button>
</form>
{/* 注册跳转 */}
<div className="text-center pt-2">
<p className="text-sm text-gray-600">
Don't have an account?{" "}
<Link href="/sign-up" className="text-gray-900 font-medium hover:underline">
Sign up
</Link>
</p>
</div>
</div>
</div>
);
}
同样地,建议也创建一个 app/sign-up/[[...sign-up]]/page.tsx 用于注册。
3. 在 .env.local 中配置路径
你需要显式告诉 Clerk 你的自定义页面地址,这样 auth.protect() 才知道往哪跳:
# .env.local
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
4. 关键配置:SSO 回调页
在使用 OAuth(如 Google)时,Clerk 需要一个页面来处理重定向回来的数据。你需要创建一个文件 app/sso-callback/page.tsx:
import { AuthenticateWithRedirectCallback } from "@clerk/nextjs";
export default function SSOCallback() {
// 这个组件是 Clerk 提供的,专门处理 OAuth 回调逻辑
return <AuthenticateWithRedirectCallback />;
}
5. 为什么这样做能解决你的问题?
- 完全去掉了默认样式: 没有阴影、没有边框、没有 "Powered by Clerk",因为你根本没用
<SignIn />组件。 - 结构完全可控: 你可以把 Google 登录放在上面,也可以放在下面,或者只显示一个图标。
- 开发环境标签: 请注意,即使使用 Headless Hooks,如果你是在开发环境(Development Instance),某些网络请求或控制台仍会提示。但 UI 上绝对不会再出现那个粉红色的浮窗了。
⚠️ 注意事项
- 错误处理: 自定义 UI 需要你自己写错误提示(比如“密码错误”)。
- 加载状态: 建议在提交时添加
loading状态防止用户重复点击。 - 安全性: Clerk 依然会在后台处理 Session 和 Cookie,安全性与默认组件一致。
你需要我帮你补充“注册(Sign Up)”部分的 Hooks 逻辑,还是针对这个登录表单做更细致的 Tailwind 样式美化?
8. 总结
- 前端:用 Clerk 的
<SignedIn>和<UserButton>搞定 UI。 - 中间层:用
middleware.ts拦截未登录访问。 - 后端:用
auth()获取userId存入数据库。
这样,你的 Demo 就从一个“匿名留言板”进化成了一个真正的“多用户博客系统”。
现在你的项目已经有了:UI (shadcn) + 数据库 (Supabase) + 逻辑 (Next.js) + 权限 (Clerk)。
支线知识 ↓
Zod
既然你熟悉 Java 体系,理解 Zod 最快的方式就是把它看作 “TypeScript 界的 Hibernate Validator (JSR-303)”。
在 Java 中,你会用 @NotNull, @Size(min=5) 来校验 DTO。在 Next.js 全栈开发中,Zod 就是干这个活的,而且它还额外承担了 “类型推导” 的工作。
1. 逐行拆解
import { z } from "zod"; // 引入 zod 库,约定俗成简写为 z
// 定义一个“校验模式”(就像在 Java 中定义一个带注解的 DTO 类)
const PostSchema = z.object({
// title 必须是字符串,且长度至少为 1,不满足则返回自定义错误消息
title: z.string().min(1, "标题不能为空"),
// content 必须是字符串,且长度至少为 5
content: z.string().min(5, "内容至少需要 5 个字"),
});
2. 它解决了什么痛点?
在 Java 中,编译器能保证类型安全。但在 TypeScript 中,运行时(Runtime) 的数据(比如用户提交的表单、API 返回的 JSON)是不可控的。
Zod 的作用是:“先校验,再使用”。
3. Java 开发者最关心的:如何使用?
定义好 PostSchema 后,你通常会有两个动作:
A. 运行时校验(相当于校验请求参数)
// 模拟一段不合法的数据
const rawData = { title: "", content: "hi" };
// 校验
const result = PostSchema.safeParse(rawData);
if (!result.success) {
// 如果校验失败,打印错误消息
console.log(result.error.format());
// 输出:{ title: { _errors: ['标题不能为空'] }, content: { _errors: ['内容至少需要 5 个字'] } }
}
B. 静态类型推导(最神奇的地方)
在 Java 里你需要手动写个 PostDTO 类。在 Zod 里,你可以直接从模式中提取出 TypeScript 类型:
// 这一行代码直接生成了一个 TS 接口/类型
type PostDTO = z.infer<typeof PostSchema>;
// 现在 PostDTO 等同于:
// interface PostDTO {
// title: string;
// content: string;
// }
4. 为什么 Prisma 学习路径中会出现 Zod?
这是一个全栈最佳实践:
- 用户前端输入:用 Zod 校验表单。
- Server Action (后端):用 Zod 二次校验(防止绕过前端)。
- Prisma:校验通过后,放心交给 Prisma 写入数据库。
这样你就构建了一套从页面到数据库的“防弹”链路。
5. 举一反三:更多常用校验
如果你想在 PostSchema 里加更多约束,非常直观:
email: z.string().email("邮箱格式不对")age: z.number().int().min(18)tags: z.array(z.string())(字符串数组)
useActionState
既然你已经了解了 "use server"; 和 Zod,那么 useActionState 就是把它们粘合在一起的**“ 胶水”**。
作为 Java 后端,你一定熟悉处理表单提交的逻辑:用户提交 -> 后端校验 -> 校验失败返回错误信息 -> 前端显示错误。在传统的 React 中,这需要写很多 useState 来管理加载状态和错误消息。
useActionState 是 React 19 和 Next.js 推荐的官方 Hook,专门用来管理 Server Action 的状态。
1. 核心定义
useActionState 是一个客户端 Hook,它接收一个 Server Action,并返回:
- 当前的 Action 状态(比如后端返回的错误消息、成功提示)。
- 一个包装后的 Action 函数(你直接绑定到 form 的
action属性上)。 - 是否正在挂起 (Pending)(类似于加载中状态)。
2. 语法结构
const [state, formAction, isPending] = useActionState(action, initialState);
action: 你写的那个带有"use server";的函数。initialState: 初始状态(通常是{ message: "" })。state: 后端返回的最新的值。formAction: 传递给<form action={...}>的函数。isPending: 布尔值,如果为true,表示后端逻辑正在执行(你可以用来禁用按钮或显示 Loading)。
3. 实战示例:Zod + Server Action + useActionState
让我们把之前的知识点串起来。
后端:actions.ts
"use server";
import { z } from "zod";
const schema = z.object({
title: z.string().min(1, "标题不能为空"),
});
export async function createPost(prevState: any, formData: FormData) {
// 模拟延迟
await new Promise((res) => setTimeout(res, 1000));
const validatedFields = schema.safeParse({
title: formData.get("title"),
});
if (!validatedFields.success) {
return { error: validatedFields.error.flatten().fieldErrors.title?.[0] };
}
// 执行 Prisma 存库操作...
return { success: "发布成功!" };
}
前端:PostForm.tsx
"use client";
import { useActionState } from "react";
import { createPost } from "./actions";
export default function PostForm() {
// 绑定 Action
const [state, formAction, isPending] = useActionState(createPost, { error: "", success: "" });
return (
<form action={formAction}>
<input name="title" className="border p-2" />
{/* 显示校验错误 */}
{state?.error && <p className="text-red-500">{state.error}</p>}
{state?.success && <p className="text-green-500">{state.success}</p>}
<button
type="submit"
disabled={isPending}
className="bg-blue-500 text-white p-2 disabled:bg-gray-400"
>
{isPending ? "提交中..." : "发布帖子"}
</button>
</form>
);
}
useTransition
既然你提到了 useActionState,那么理解 useTransition 就非常简单了。它们两个长得很像,但分工不同。
简单来说:useActionState 专门用于管理“表单”的状态,而 useTransition 是一个更通用的“手动挡”,用于处理任何不依赖于自动表单提交的异步操作。
1. 核心定义
useTransition 是 React 提供的一个 Hook,它可以让你在不阻塞 UI 的情况下更新状态。它会给你一个 isPending 标志,告诉你异步任务(比如数据库操作)是否正在执行。
2. 语法结构
const [isPending, startTransition] = useTransition();
isPending: 告诉你是“正在处理中”。startTransition: 一个函数,你把需要执行的异步逻辑(比如调用 Prisma 的 Server Action)丢进去。
3. Java 开发者视角的对比:它和 useActionState 有什么区别?
我们可以用一个简单的表格来区分:
| 特性 | useActionState (自动挡) | useTransition (手动挡) |
|---|---|---|
| 主要用途 | 处理 <form> 表单提交 | 处理 点击事件(按钮、删除图标等) |
| 数据管理 | 自动管理后端返回的 state (错误信息等) | 只负责管理 isPending 状态,不存返回数据 |
| 触发方式 | 通过 form 的 action 属性触发 | 通过 startTransition(() => { ... }) 手动触发 |
4. 实战场景:删除一个用户
假设你在做一个用户列表,每个用户后面有一个“删除”按钮。这里没有表单,最适合用 useTransition。
后端:actions.ts
"use server";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
export async function deleteUser(id: number) {
await prisma.user.delete({ where: { id } });
revalidatePath("/users"); // 告诉 Next.js 刷新页面数据
}
前端:UserRow.tsx
"use client";
import { useTransition } from "react";
import { deleteUser } from "./actions";
export default function UserRow({ user }) {
const [isPending, startTransition] = useTransition();
return (
<div className={isPending ? "opacity-50" : "opacity-100"}>
<span>{user.name}</span>
<button
disabled={isPending}
onClick={() => {
// 手动包裹异步逻辑
startTransition(async () => {
await deleteUser(user.id);
});
}}
>
{isPending ? "删除中..." : "删除"}
</button>
</div>
);
}
5. 为什么要用它?(性能与体验)
在 React 18 之前,当你点击删除按钮,页面可能“卡死”一下直到后端响应。
使用了 useTransition 后:
- UI 响应极快:React 会把
startTransition里的操作标记为“非紧急”。 - 状态反馈:你可以利用
isPending立刻给用户一个视觉反馈(比如让按钮变灰或显示菊花图),这和你在 Vue 中手动维护loading变量的目的一样,但代码更简洁。
总结你的“工具箱”
现在你的全栈开发工具箱里有这三样东西:
- 直接调用:在 Server Component 里直接
await prisma...(用于初始化加载数据)。 useActionState:用于 Form 表单(登录、注册、发布文章)。useTransition:用于 独立按钮操作(点赞、删除、切换状态)。
.bind
在 Next.js 的 Server Actions 开发中,.bind 是一个非常关键的技巧。
作为 Java 开发者,你可能习惯了通过 URL 传参(如 /api/user/{id})或者在 DTO 里加隐藏字段。但在 Next.js 中,.bind 提供了一种更优雅、类型安全的方式来给后端函数“预传参数”。
1. 核心定义
.bind 是 JavaScript 的原生方法。它会创建一个新函数,并在调用时,将你指定的参数“锁死”在函数的前几个参数位上。
在 Next.js 中,它主要用于:在不使用隐藏 <input> 的情况下,将特定数据(如 ID)传给 Server Action。
2. 为什么要用它?(对比 Java 思路)
假设你要实现“删除文章”功能,后端需要一个 postId。
传统思路(Java/Vue):
你在表单里写一个 <input type="hidden" name="id" value="123" />。这样提交时,后端能从 FormData 里拿到 ID。
Next.js + .bind 思路:
你直接把 ID “绑定”到函数上,不需要在 HTML 里塞隐藏域。
3. 代码演示
后端 Action (actions.ts):
注意这里的参数顺序,绑定后的参数会出现在 formData 之前。
"use server";
// postId 是我们通过 .bind 传进来的
export async function deletePost(postId: number, formData: FormData) {
await prisma.post.delete({ where: { id: postId } });
}
前端组件 (DeleteButton.tsx):
"use client";
import { deletePost } from "./actions";
export default function DeleteButton({ id }: { id: number }) {
// 使用 .bind 锁死第一个参数为当前文章的 id
// 第一个参数是 this 的指向(通常传 null),第二个参数开始才是我们要传的值
const deletePostWithId = deletePost.bind(null, id);
return (
<form action={deletePostWithId}>
<button type="submit">删除文章 {id}</button>
</form>
);
}
4. 它的三大优势
- 安全性:数据在服务器端生成的函数中闭包存储,不像
type="hidden"那样容易在浏览器 F12 被用户篡改值。 - 类型安全:如果你在
actions.ts里定义第一个参数是number,而你在.bind时传了string,TypeScript 会直接报错。这比从FormData里手动get出来再转型要稳健得多。 - 代码简洁:不需要在表单里维护各种隐藏字段,逻辑更清晰。
5. Java 视角深度类比
你可以把 .bind 看作是一种 “动态创建的带参构造函数”。
- 普通 Action: 就像一个无参构造的类,只能通过
setter(FormData) 传值。 .bind后的 Action: 就像是一个已经通过构造函数注入了特定ID的 Service 对象,你只需要调用它的execute()方法即可,它已经知道自己要操作哪个 ID 了。
解构语法
对于 Java 开发者来说,解构(Destructuring) 是 JavaScript/TypeScript 中最能提高“爽感”的语法之一。
在 Java 中,如果你想从一个对象里拿几个属性,你得不停地调用 getter;但在 JS 中,你可以直接把对象“拆解”开。
1. 对象解构 (Object Destructuring)
这是你之前看到的语法:const { id: idStr } = await params;
Java 的写法 (对比)
// 假设 Params 是一个类
Params p = params.get();
String idStr = p.getId();
JavaScript 的写法
const user = { name: "张三", age: 18, city: "北京" };
// 基础写法:变量名必须和属性名一致
const { name, age } = user;
console.log(name); // "张三"
// 进阶写法:起别名(你代码里的用法)
const { name: userName } = user;
console.log(userName); // "张三"
2. 数组解构 (Array Destructuring)
你在 useActionState 或 useState 中看到的 [state, formAction] 就是数组解构。
语法示例
const colors = ["red", "green", "blue"];
// 按照顺序取出前两个
const [firstColor, secondColor] = colors;
console.log(firstColor); // "red"
Java 视角: 这在 Java 里通常需要通过索引 colors[0] 来获取,解构让它变得非常直观。
3. 函数参数解构 (最常用的场景)
在 Next.js 的组件开发中,你会疯狂用到这个。
普通写法
function UserCard(props) {
return <div>{props.name} - {props.age}</div>
}
解构写法 (推荐)
// 直接在参数位把 props 拆开
function UserCard({ name, age }) {
return <div>{name} - {age}</div>
}
4. 为什么解构在 Prisma/Next.js 中很重要?
- 处理异步数据:如你所见,
const { id } = await params。因为params是个 Promise,我们先await拿到结果对象,然后瞬间解构出我们要的字段。 - 减少冗余:当一个对象有 20 个属性,而你只需要其中 2 个时,解构能让代码清爽很多。
- 配合 Spread (展开运算符):
const { id, ...rest } = post;
// id 拿出来了,剩下的属性全在 rest 这个对象里
// 类似于 Java 中从 Map 里 remove 掉一个 key,剩下的组成新 Map
5. 一个容易踩的坑 (Java 开发注意)
- 必须同名:解构对象时,变量名必须和对象里的 Key 一模一样。如果不一样,必须用冒号起别名,否则拿到的就是
undefined。 - 空值检查:如果被解构的对象是
null或undefined,代码会直接崩溃(抛出 TypeError)。所以通常要配合可选链或者默认值。
const { name = "匿名" } = user || {}; // 这种防御性写法在 JS 中很常见
总结
{ }用于解构对象。[ ]用于解构数组。:用于起别名。=用于设置默认值。
你现在已经掌握了 TS 中最灵活的语法!既然你已经理解了如何获取 ID,下一步我们是不是该聊聊如何用这个 ID 去 Prisma 里做 update 更新操作了?