Skip to content

API 开发

IAM 项目使用 Next.js + Hono + tRPC + Drizzle 技术栈,提供端到端类型安全的 API。Drizzle 位于 Monorepo 的 packages/db 中,Next.js 与 Hono 均可直接连接数据库,因此存在多种数据访问路径,需按场景选择。

packages/api/src/routers/ 目录下创建新的路由文件:

import { z } from 'zod'
import { publicProcedure, protectedProcedure, router } from '../index'
export const exampleRouter = router({
// 公共过程
publicEndpoint: publicProcedure.query(() => {
return { message: 'This is public' }
}),
// 受保护的过程
privateEndpoint: protectedProcedure.query(({ ctx }) => {
return {
message: 'This is private',
userId: ctx.session.user.id
}
}),
// 带输入验证的过程
createItem: protectedProcedure
.input(
z.object({
name: z.string().min(1),
description: z.string().optional()
})
)
.mutation(async ({ input, ctx }) => {
// 处理创建逻辑
return { id: '123', ...input }
})
})
  • publicProcedure: 公共过程,不需要认证
  • protectedProcedure: 受保护的过程,需要认证
  • query: 用于读取数据(GET 请求)
  • mutation: 用于修改数据(POST/PUT/DELETE 请求)

packages/api/src/routers/index.ts 中注册新路由:

import { exampleRouter } from './example'
export const appRouter = router({
example: exampleRouter
// ... 其他路由
})
export type AppRouter = typeof appRouter

在 React 组件中使用 tRPC:

import { trpc } from "@/utils/trpc";
function MyComponent() {
// Query - 用于读取数据
const { data, isLoading, error } = trpc.example.publicEndpoint.useQuery();
// Mutation - 用于修改数据
const createMutation = trpc.example.createItem.useMutation();
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
<p>{data?.message}</p>
<button
onClick={() => createMutation.mutate({
name: "Item",
description: "Description"
})}
>
Create
</button>
</div>
);
}
const { data } = trpc.example.publicEndpoint.useQuery(
undefined, // 输入参数
{
enabled: shouldFetch, // 条件查询
refetchOnWindowFocus: false, // 窗口聚焦时不重新获取
staleTime: 5000 // 数据过期时间
}
)

tRPC 自动处理错误,可以在前端捕获:

const mutation = trpc.example.createItem.useMutation({
onError: (error) => {
console.error('Error:', error.message)
// 显示错误提示
},
onSuccess: (data) => {
console.log('Success:', data)
// 显示成功提示
// 刷新列表
}
})

在 tRPC 过程中抛出错误:

import { TRPCError } from '@trpc/server'
protectedProcedure.input(z.object({ id: z.string() })).query(async ({ input }) => {
const item = await findItem(input.id)
if (!item) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Item not found'
})
}
return item
})
  • BAD_REQUEST: 400 - 请求参数错误
  • UNAUTHORIZED: 401 - 未授权
  • FORBIDDEN: 403 - 禁止访问
  • NOT_FOUND: 404 - 资源不存在
  • INTERNAL_SERVER_ERROR: 500 - 服务器错误

使用 Zod 进行输入验证:

import { z } from 'zod'
export const exampleRouter = router({
createUser: publicProcedure
.input(
z.object({
email: z.string().email(),
name: z.string().min(2).max(100),
age: z.number().int().min(18).max(120)
})
)
.mutation(async ({ input }) => {
// input 已经通过验证
return createUser(input)
})
})

在 tRPC 过程中使用数据库:

import { db } from '@IAM/db'
import { posts } from '@IAM/db/schema'
import { eq } from 'drizzle-orm'
export const postsRouter = router({
list: publicProcedure.query(async () => {
return await db.select().from(posts)
}),
getById: publicProcedure.input(z.object({ id: z.string() })).query(async ({ input }) => {
const [post] = await db.select().from(posts).where(eq(posts.id, input.id))
return post
}),
create: protectedProcedure
.input(
z.object({
title: z.string().min(1),
content: z.string()
})
)
.mutation(async ({ input, ctx }) => {
const [post] = await db
.insert(posts)
.values({
id: generateId(),
title: input.title,
content: input.content,
authorId: ctx.session.user.id
})
.returning()
return post
})
})

在 Monorepo 中,Drizzle 是架构里的「自由人」:既可在 Next.js 中作为快速通道直连数据库,也可在 Hono/tRPC 中作为标准数据层。按场景选择以下策略。

策略 1:Next.js RSC 直连数据库(最快读取)

Section titled “策略 1:Next.js RSC 直连数据库(最快读取)”

场景:首屏渲染、SEO 数据、静态/增量静态生成(SSG/ISR)。

路径:Next.js Page → Drizzle → Database(不经过 Hono API)

无需让 Next.js 再发 HTTP 请求到 Hono,在服务端组件中直接导入 db 并查询即可,零网络延迟。

apps/web/app/dashboard/page.tsx
import { db } from '@IAM/db'
import { users } from '@IAM/db/schema'
import { eq } from 'drizzle-orm'
export default async function Dashboard() {
const user = await db.query.users.findFirst({
where: eq(users.id, 'some-id')
})
return <h1>欢迎回来, {user?.name}</h1>
}

推荐指数:⭐⭐⭐⭐⭐(首屏/SEO 最优)

策略 2:Hono + tRPC + Drizzle(标准交互)

Section titled “策略 2:Hono + tRPC + Drizzle(标准交互)”

场景:客户端交互、搜索、分页、复杂业务逻辑。

路径:浏览器 → tRPC Client → Hono → Drizzle → Database

用户操作时请求发往 Hono,Hono 通过 Drizzle 访问数据库,前后端类型安全贯通,逻辑集中在后端。

// packages/api 或 apps/server 中的 tRPC Router
import { db } from '@IAM/db'
import { users } from '@IAM/db/schema'
export const userRouter = router({
list: publicProcedure.query(async () => {
return await db.select().from(users)
})
})

推荐指数:⭐⭐⭐⭐⭐(交互与类型体验最佳)

策略 3:Server Actions + Drizzle(混合变体)

Section titled “策略 3:Server Actions + Drizzle(混合变体)”

场景:简单数据修改(Mutation)、表单提交。

路径:浏览器 Form → Next.js Server Action → Drizzle → Database

  • 选项 A(简单场景):Server Action 直接调用 Drizzle 写库。适合「修改昵称」、「点赞」等简单操作,开发快、代码少。
  • 选项 B(复杂/需复用):Server Action 通过 fetch 调用 Hono API,由 Hono 再调用 Drizzle。适合创建订单、支付等需复用逻辑或触发 Webhook 的场景,便于 App 端等复用同一套 API。

推荐:默认使用选项 A(直连),仅在逻辑复杂或需多端复用时采用选项 B。

apps/web/actions/user.ts
'use server'
import { db } from '@IAM/db'
import { users } from '@IAM/db/schema'
import { eq } from 'drizzle-orm'
import { revalidatePath } from 'next/cache'
export async function updateNameAction(userId: string, newName: string) {
await db.update(users).set({ name: newName }).where(eq(users.id, userId))
revalidatePath('/dashboard')
}

推荐指数:⭐⭐⭐⭐(选项 A)/ ⭐⭐⭐(选项 B,逻辑复用时)

场景前端发起方式后端执行位置数据访问方式(Drizzle)推荐指数
首屏加载(SEO)浏览器访问 URLNext.js RSCRSC 直连 db.query⭐⭐⭐⭐⭐
页面交互(加载更多)trpc.useQueryHono (tRPC)Hono 调用 db.select⭐⭐⭐⭐⭐
提交表单(简单)<form action>Server ActionAction 直连 db.update⭐⭐⭐⭐
提交表单(复杂)<form action>Server Action → HonoAction fetch Hono API⭐⭐⭐
文件上传fetch('/api/upload')Hono (REST)Hono 记录到 DB⭐⭐⭐⭐⭐

Next.js(Serverless/Edge)与 Hono(Long-running/Edge)都会使用 Drizzle,务必在 packages/db 中配置好连接池(例如 @neondatabase/serverless 或 PgBouncer),避免 Serverless 函数短时间创建大量连接导致数据库连接数耗尽。

文件上传使用 REST 接口(multipart),与 tRPC 逻辑一致,便于浏览器通过 FormData 上传。

  • 方法: POST /api/upload
  • 认证: 需要登录态(Better Auth session),未登录返回 401
  • Content-Type: multipart/form-data
  • 请求体字段:
    • pathname(string):存储路径,用于在 Vercel Blob 中标识文件
    • body(File):要上传的文件
  • 成功响应: 200{ url: string },为文件的公开访问 URL
  • 错误响应:
    • 400: 缺少 pathnamebody(或 body 不是 File)
    • 401: 未授权
const res = await fetch(`${env.NEXT_PUBLIC_SERVER_URL}/api/upload`, {
method: 'POST',
headers: { Authorization: `Bearer ${session.session.token}` }, // 若使用 cookie 则可不带
body: (() => {
const formData = new FormData()
formData.set('pathname', 'avatars/user-123.png')
formData.set('body', file) // File 来自 input[type="file"] 或拖拽
return formData
})()
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(err.error ?? 'Upload failed')
}
const { url } = await res.json()
// 使用 url 作为图片等资源的地址

服务端使用 @vercel/blobput(pathname, file, { access: 'public' }),上传后返回的 URL 为公开可访问。

  1. 类型安全: 始终使用 TypeScript 和 Zod 验证
  2. 错误处理: 提供清晰的错误消息
  3. 权限检查: 在受保护的过程中验证权限
  4. 性能优化: 使用数据库索引和查询优化
  5. 代码组织: 将相关路由组织在同一文件中
  6. 数据访问层: 按场景选择 RSC 直连、tRPC+Hono 或 Server Actions,并确保 packages/db 配置连接池(见上文「数据访问层策略」)