跳到主要内容

如何在 Next.js 中将 Prisma ORM 与 Clerk Auth 结合使用

25 分钟

引言

Clerk 是一个即插即用的身份验证提供商,它处理注册、登录、用户管理和 webhook,让您无需亲自处理这些繁琐的工作。

在本指南中,您将把 Clerk 集成到一个全新的 Next.js 应用中,将用户持久化到 Prisma Postgres 数据库,并提供一个简单的文章 API。您可以在 GitHub 上找到本指南的完整示例。

先决条件

1. 设置您的项目

创建应用

npx create-next-app@latest clerk-nextjs-prisma

它将提示您自定义设置。选择默认选项

信息
  • 您想使用 TypeScript 吗?
  • 您想使用 ESLint 吗?
  • 您想使用 Tailwind CSS 吗?
  • 您想将代码放在 src/ 目录中吗?
  • 您想使用 App Router 吗?(推荐)
  • 您想为 next dev 使用 Turbopack 吗?
  • 您想自定义导入别名吗?(默认是 @/*

进入项目目录

cd clerk-nextjs-prisma

2. 设置 Clerk

2.1. 创建一个新的 Clerk 应用

前往 Clerk 的 New App 页面,创建一个新的应用。输入标题,选择您的登录选项,然后点击 Create Application

信息

在本指南中,将使用 Google、Github 和电子邮件登录选项。

安装 Clerk Next.js SDK

npm install @clerk/nextjs

复制您的 Clerk 密钥,并将其粘贴到项目根目录下的 .env 文件中

.env
# Clerk
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=<your-publishable-key>
CLERK_SECRET_KEY=<your-secret-key>

2.2. 使用 Clerk middleware 保护路由

clerkMiddleware 辅助函数启用身份验证,并在此处配置您的受保护路由。

在项目 /src 目录下创建一个 middleware.ts 文件

src/middleware.ts
import { clerkMiddleware } from "@clerk/nextjs/server";

export default clerkMiddleware();

export const config = {
matcher: [
'/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
'/(api|trpc)(.*)',
],
};

2.3. 将 Clerk UI 添加到您的布局

接下来,您需要将您的应用包裹在 ClerkProvider 组件中,以使身份验证全局可用。

在您的 layout.tsx 文件中,添加 ClerkProvider 组件

src/app/layout.tsx
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { ClerkProvider } from "@clerk/nextjs";

const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});

const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});

export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};

export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<ClerkProvider>
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
{children}
</body>
</html>
</ClerkProvider>
);
}

创建一个 Navbar 组件,用于显示登录和注册按钮,以及用户登录后的用户按钮

src/app/layout.tsx
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import {
ClerkProvider,
UserButton,
SignInButton,
SignUpButton,
SignedOut,
SignedIn,
} from "@clerk/nextjs";

const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});

const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});

export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};

export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<ClerkProvider>
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
<Navbar />
{children}
</body>
</html>
</ClerkProvider>
);
}

const Navbar = () => {
return (
<header className="flex justify-end items-center p-4 gap-4 h-16">
<SignedOut>
<SignInButton />
<SignUpButton />
</SignedOut>
<SignedIn>
<UserButton />
</SignedIn>
</header>
);
};

3. 安装和配置 Prisma

3.1. 安装依赖

要开始使用 Prisma,您需要安装一些依赖项

npm install prisma --save-dev
npm install tsx --save-dev
npm install @prisma/extension-accelerate

安装完成后,在您的项目中初始化 Prisma

npx prisma init --db --output ../src/app/generated/prisma
信息

在设置 Prisma Postgres 数据库时,您需要回答几个问题。选择离您位置最近的区域,并为数据库选择一个容易记住的名称,例如“我的 Clerk NextJS 项目”

这将创建

  • 一个 prisma/ 目录,其中包含一个 schema.prisma 文件
  • .env 文件中的 DATABASE_URL

3.2. 定义您的 Prisma Schema

prisma/schema.prisma 文件中,添加以下模型

prisma/schema.prisma
generator client {
provider = "prisma-client-js"
output = "../src/app/generated/prisma"
}

datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}

model User {
id Int @id @default(autoincrement())
clerkId String @unique
email String @unique
name String?
posts Post[]
}

model Post {
id Int @id @default(autoincrement())
title String
content String?
published Boolean @default(false)
authorId Int
author User @relation(fields: [authorId], references: [id])
createdAt DateTime @default(now())
}

这将创建两个模型:UserPost,它们之间存在一对多关系。

现在,运行以下命令来创建数据库表并生成 Prisma Client

npx prisma migrate dev --name init
警告

建议您将 /src/app/generated/prisma 添加到您的 .gitignore 文件中。

3.3. 创建一个可复用的 Prisma Client

src/ 目录下,创建 /lib 目录并在其中创建一个 prisma.ts 文件

src/lib/prisma.ts
import { PrismaClient } from "@/app/generated/prisma";
import { withAccelerate } from "@prisma/extension-accelerate";

const globalForPrisma = global as unknown as {
prisma: PrismaClient;
};

const prisma =
globalForPrisma.prisma || new PrismaClient().$extends(withAccelerate());

if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

export default prisma;

4. 将 Clerk 连接到数据库

4.1. 创建一个 Clerk webhook 端点

您将使用 Svix 来确保请求是安全的。Svix 会签署每个 webhook 请求,以便您可以验证其合法性,并确保在传输过程中未被篡改。

安装 svix

npm install svix

src/app/api/webhooks/clerk/route.ts 创建一个新的 API 路由

导入必要的依赖项

src/app/api/webhooks/clerk/route.ts
import { Webhook } from "svix";
import { WebhookEvent } from "@clerk/nextjs/server";
import { headers } from "next/headers";
import prisma from "@/lib/prisma";

创建 Clerk 将调用的 POST 方法并验证有效载荷。

首先,它会检查 Signing Secret 是否已设置

src/app/api/webhooks/clerk/route.ts
import { Webhook } from "svix";
import { WebhookEvent } from "@clerk/nextjs/server";
import { headers } from "next/headers";
import prisma from "@/lib/prisma";

export async function POST(req: Request) {
const secret = process.env.SIGNING_SECRET;
if (!secret) return new Response("Missing secret", { status: 500 });
}
注意

Signing Secret 在您的 Clerk 应用的 Webhooks 部分中可用。目前您应该还没有这个,您将在接下来的几个步骤中进行设置。

现在,创建 Clerk 将调用的 POST 方法并验证有效载荷

src/app/api/webhooks/clerk/route.ts
import { Webhook } from "svix";
import { WebhookEvent } from "@clerk/nextjs/server";
import { headers } from "next/headers";
import prisma from "@/lib/prisma";

export async function POST(req: Request) {
const secret = process.env.SIGNING_SECRET;
if (!secret) return new Response("Missing secret", { status: 500 });

const wh = new Webhook(secret);
const body = await req.text();
const headerPayload = await headers();

const event = wh.verify(body, {
"svix-id": headerPayload.get("svix-id")!,
"svix-timestamp": headerPayload.get("svix-timestamp")!,
"svix-signature": headerPayload.get("svix-signature")!,
}) as WebhookEvent;
}

当创建新用户时,需要将他们存储到数据库中。

您可以通过检查事件类型是否为 user.created,然后使用 Prisma 的 upsert 方法在用户不存在时创建新用户来实现。

src/app/api/webhooks/clerk/route.ts
import { Webhook } from "svix";
import { WebhookEvent } from "@clerk/nextjs/server";
import { headers } from "next/headers";
import prisma from "@/lib/prisma";

export async function POST(req: Request) {
const secret = process.env.SIGNING_SECRET;
if (!secret) return new Response("Missing secret", { status: 500 });

const wh = new Webhook(secret);
const body = await req.text();
const headerPayload = await headers();

const event = wh.verify(body, {
"svix-id": headerPayload.get("svix-id")!,
"svix-timestamp": headerPayload.get("svix-timestamp")!,
"svix-signature": headerPayload.get("svix-signature")!,
}) as WebhookEvent;

if (event.type === "user.created") {
const { id, email_addresses, first_name, last_name } = event.data;
await prisma.user.upsert({
where: { clerkId: id },
update: {},
create: {
clerkId: id,
email: email_addresses[0].email_address,
name: `${first_name} ${last_name}`,
},
});
}
}

最后,向 Clerk 返回响应以确认已接收到 webhook

src/app/api/webhooks/clerk/route.ts
import { Webhook } from "svix";
import { WebhookEvent } from "@clerk/nextjs/server";
import { headers } from "next/headers";
import prisma from "@/lib/prisma";

export async function POST(req: Request) {
const secret = process.env.SIGNING_SECRET;
if (!secret) return new Response("Missing secret", { status: 500 });

const wh = new Webhook(secret);
const body = await req.text();
const headerPayload = await headers();

const event = wh.verify(body, {
"svix-id": headerPayload.get("svix-id")!,
"svix-timestamp": headerPayload.get("svix-timestamp")!,
"svix-signature": headerPayload.get("svix-signature")!,
}) as WebhookEvent;

if (event.type === "user.created") {
const { id, email_addresses, first_name, last_name } = event.data;
await prisma.user.upsert({
where: { clerkId: id },
update: {},
create: {
clerkId: id,
email: email_addresses[0].email_address,
name: `${first_name} ${last_name}`,
},
});
}

return new Response("OK");
}

4.2. 暴露您的本地应用以供 webhook 使用

您需要使用 ngrok 暴露您的本地应用以供 webhook 使用。这将允许 Clerk 访问您的 /api/webhooks/clerk 路由来推送 user.created 等事件。

安装 ngrok 并暴露您的本地应用

npm install --global ngrok
ngrok http 3000

复制 ngrok 的 Forwarding URL。这将用于在 Clerk 中设置 webhook URL。

导航到您的 Clerk 应用中的 Webhooks 部分,该部分位于 Developers 下方的 Configure 选项卡底部附近。

点击 Add Endpoint 并将 ngrok URL 粘贴到 Endpoint URL 字段中,然后在 URL 的末尾添加 /api/webhooks/clerk。它应该看起来类似于这样

https://a60b-99-42-62-240.ngrok-free.app/api/webhooks/clerk

复制 Signing Secret 并将其添加到您的 .env 文件中

.env
# Prisma
DATABASE_URL=<your-database-url>

# Clerk
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=<your-publishable-key>
CLERK_SECRET_KEY=<your-secret-key>
SIGNING_SECRET=<your-signing-secret>

在主页上,点击 Sign Up 并使用任意注册选项创建一个账户

打开 Prisma Studio,您应该会看到一条用户记录。

npx prisma studio
注意

如果您没有看到用户记录,有几件事需要检查:

  • 从 Clerk 的 Users 选项卡中删除您的用户,然后重试。
  • 检查您的 ngrok URL 并确保其正确(每次重启 ngrok 时 URL 都会改变)。
  • 检查您的 Clerk webhook 是否指向正确的 ngrok URL。
  • 确保您已在 URL 的末尾添加了 /api/webhooks/clerk

5. 构建文章 API

要在用户下创建文章,您需要在 src/app/api/posts/route.ts 创建一个新的 API 路由

首先导入必要的依赖项

src/app/api/posts/route.ts
import { auth } from "@clerk/nextjs/server";
import prisma from "@/lib/prisma";

获取已验证用户的 clerkId。如果不存在用户,则返回 401 Unauthorized 响应。

src/app/api/posts/route.ts
import { auth } from "@clerk/nextjs/server";
import prisma from "@/lib/prisma";

export async function POST(req: Request) {
const { userId: clerkId } = await auth();
if (!clerkId) return new Response("Unauthorized", { status: 401 });
}

将 Clerk 用户与数据库中的用户进行匹配。如果未找到用户,则返回 404 Not Found 响应。

src/app/api/posts/route.ts
import { auth } from "@clerk/nextjs/server";
import prisma from "@/lib/prisma";

export async function POST(req: Request) {
const { userId: clerkId } = await auth();
if (!clerkId) return new Response("Unauthorized", { status: 401 });

const user = await prisma.user.findUnique({
where: { clerkId },
});

if (!user) return new Response("User not found", { status: 404 });
}

从传入请求中解构 titlecontent 并创建一篇文章。完成后,返回 201 Created 响应。

src/app/api/posts/route.ts
import { auth } from "@clerk/nextjs/server";
import prisma from "@/lib/prisma";

export async function POST(req: Request) {
const { userId: clerkId } = await auth();
if (!clerkId) return new Response("Unauthorized", { status: 401 });

const { title, content } = await req.json();

const user = await prisma.user.findUnique({
where: { clerkId },
});

if (!user) return new Response("User not found", { status: 404 });

const post = await prisma.post.create({
data: {
title,
content,
authorId: user.id,
},
});

return new Response(JSON.stringify(post), { status: 201 });
}

6. 添加文章创建 UI

/app 目录下,创建一个 /components 目录并在其中创建一个 PostInputs.tsx 文件

src/app/components/PostInputs.tsx
"use client";

import { useState } from "react";

export default function PostInputs() {
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
}

此组件使用 "use client" 来确保组件在客户端渲染。titlecontent 存储在它们各自的 useState 钩子中。

创建一个函数,当表单提交时将被调用

src/app/components/PostInputs.tsx
"use client";

import { useState } from "react";

export default function PostInputs() {
const [title, setTitle] = useState("");
const [content, setContent] = useState("");

async function createPost(e: React.FormEvent) {
e.preventDefault();
if (!title || !content) return;

await fetch("/api/posts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, content }),
});

setTitle("");
setContent("");
location.reload();
}
}

您将使用一个表单来创建文章并调用之前创建的 POST 路由

src/app/page.tsx
"use client";

import { useState } from "react";

export default function PostInputs() {
const [title, setTitle] = useState("");
const [content, setContent] = useState("");

async function createPost(e: React.FormEvent) {
e.preventDefault();
if (!title || !content) return;

await fetch("/api/posts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, content }),
});

setTitle("");
setContent("");
location.reload();
}

return (
<form onSubmit={createPost} className="space-y-2">
<input
type="text"
placeholder="Title"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full p-2 border border-zinc-800 rounded"
/>
<textarea
placeholder="Content"
value={content}
onChange={(e) => setContent(e.target.value)}
className="w-full p-2 border border-zinc-800 rounded"
/>
<button className="w-full p-2 border border-zinc-800 rounded">
Post
</button>
</form>
);
}

提交时

  • 它向 /api/posts 路由发送一个 POST 请求
  • 清除输入字段
  • 重新加载页面以显示新文章

7. 设置 page.tsx

现在,更新 page.tsx 文件以获取文章、显示表单并渲染列表。

删除 page.tsx 中的所有内容,只保留以下内容

src/app/page.tsx
export default function Home() {
return ()
}

导入必要的依赖项

src/app/page.tsx
import { currentUser } from "@clerk/nextjs/server";
import prisma from "@/lib/prisma";
import PostInputs from "@/app/components/PostInputs";

export default function Home() {
return ()
}

为确保只有已登录用户才能访问文章功能,请更新 Home 组件以检查是否存在用户

src/app/page.tsx
import { currentUser } from "@clerk/nextjs/server";
import prisma from "@/lib/prisma";
import PostInputs from "@/app/components/PostInputs";

export default async function Home() {
const user = await currentUser();
if (!user) return <div className="flex justify-center">Sign in to post</div>;

return ()
}

找到用户后,从数据库中获取该用户的文章

src/app/page.tsx
import { currentUser } from "@clerk/nextjs/server";
import prisma from "@/lib/prisma";
import PostInputs from "@/app/components/PostInputs";

export default async function Home() {
const user = await currentUser();
if (!user) return <div className="flex justify-center">Sign in to post</div>;

const posts = await prisma.post.findMany({
where: { author: { clerkId: user.id } },
orderBy: { createdAt: "desc" },
});

return ()
}

最后,渲染表单和文章列表

src/app/page.tsx
import { currentUser } from "@clerk/nextjs/server";
import prisma from "@/lib/prisma";
import PostInputs from "@/app/components/PostInputs";

export default async function Home() {
const user = await currentUser();
if (!user) return <div className="flex justify-center">Sign in to post</div>;

const posts = await prisma.post.findMany({
where: { author: { clerkId: user.id } },
orderBy: { createdAt: "desc" },
});

return (
<main className="max-w-2xl mx-auto p-4">
<PostInputs />
<div className="mt-8">
{posts.map((post) => (
<div
key={post.id}
className="p-4 border border-zinc-800 rounded mt-4">
<h2 className="font-bold">{post.title}</h2>
<p className="mt-2">{post.content}</p>
</div>
))}
</div>
</main>
);
}

您已成功使用 Clerk 身份验证和 Prisma 构建了一个 Next.js 应用,为安全、可扩展的全栈应用奠定了基础,该应用能够轻松处理用户管理和数据持久化。

以下是接下来可以探索的一些步骤,以及更多有助于您开始扩展项目的资源。

后续步骤

  • 为文章和用户添加删除功能。
  • 添加一个搜索栏来过滤文章。
  • 部署到 Vercel 并在 Clerk 中设置您的生产环境 webhook URL。
  • 使用 Prisma Postgres 启用查询缓存以获得更好的性能

更多信息


与 Prisma 保持联系

通过以下方式继续您的 Prisma 之旅: 我们活跃的社区。保持了解,积极参与,并与其他开发者协作

我们真心重视您的参与,并期待您成为我们社区的一员!