Next.js + tRPC:独立开发者的最佳实践技术栈
- Published on
- • 12 mins read
作为独立开发者,选择合适的技术栈是项目成功的关键。在经历了多次技术选型的纠结后,我发现 Next.js + tRPC 这个组合堪称独立开发的黄金搭档。本文会系统地聊聊:为什么这个技术栈如此适合独立开发者,以及如何在实际项目中落地。
为什么 Next.js + tRPC 是独立开发的理想选择?
1. 端到端类型安全
传统的前后端分离开发中,API 接口的类型定义往往是最大的痛点,而 tRPC 用“从类型出发的 API 设计”很好地解决了这个问题:
// 后端 API 定义
export const userRouter = router({
getById: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
return await db.user.findUnique({
where: { id: input.id },
});
}),
create: publicProcedure
.input(
z.object({
name: z.string().min(2),
email: z.string().email(),
}),
)
.mutation(async ({ input }) => {
return await db.user.create({
data: input,
});
}),
});
// 前端调用,完全类型安全
const CreateUser = () => {
const createUser = trpc.user.create.useMutation();
const handleSubmit = (data: { name: string; email: string }) => {
// TypeScript 会检查类型,IDE 有完整提示
createUser.mutate(data);
};
};
关键收益:
- 不再需要手写 OpenAPI / Swagger 文档
- 类型从后端“一路流”到前端组件
- 重构字段时,TS 编译器会帮你找出所有受影响的调用点
2. 开发效率极高
- 无需额外 API 层样板代码:直接在 tRPC router 中写业务逻辑
- 实时类型反馈:后端 schema 一改,前端 IDE 立刻红线提醒
- 一套思维模型:统一用 TypeScript + Zod 建模,而不是在多种 DSL 间来回切换
3. 极佳的 DX(开发者体验)
// 一个完整的 tRPC 查询,包含加载状态和错误处理
const ProfilePage = () => {
const { data: user, isLoading, error } = trpc.user.getProfile.useQuery()
if (isLoading) return <Spinner />
if (error) return <ErrorMessage error={error.message} />
return <UserProfile user={user} />
}
你可以把“数据获取 + 状态管理 + 错误处理”都收敛到一套熟悉的 React Query 模型里,对独立开发者非常友好。
技术栈整体架构
核心框架层
{
"dependencies": {
"next": "^14.0.0",
"@trpc/server": "^10.45.0",
"@trpc/client": "^10.45.0",
"@trpc/react-query": "^10.45.0",
"@trpc/next": "^10.45.0"
}
}
数据库 & ORM:Prisma + PostgreSQL
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
name String?
posts Post[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Post {
id String @id @default(cuid())
title String
content String?
published Boolean @default(false)
author User @relation(fields: [authorId], references: [id])
authorId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
验证层:Zod
Zod 负责运行时校验和类型推导:
import { z } from "zod";
export const createPostSchema = z.object({
title: z.string().min(1, "标题不能为空").max(100, "标题过长"),
content: z.string().min(10, "内容至少10个字符"),
published: z.boolean().default(false),
});
export type CreatePostInput = z.infer<typeof createPostSchema>;
身份验证:NextAuth.js
// lib/auth.ts
import NextAuth from 'next-auth'
import GoogleProvider from 'next-auth/providers/google'
import { PrismaAdapter } from '@next-auth/prisma-adapter'
import { prisma } from './prisma'
export const authOptions = {
adapter: PrismaAdapter(prisma),
providers: [
GoogleProvider({
clientId:
process.env.GOOGLE
_CLIENT_ID!,
clientSecret:
process.env.GOOGLE
_CLIENT_SECRET!,
}),
],
callbacks: {
session: ({ session, user }) => ({
...session,
user: {
...session.user,
id:
user.id
,
},
}),
},
}
export default NextAuth(authOptions)
推荐项目结构
my-app/
├── src/
│ ├── app/ # Next.js 13+ App Router
│ │ ├── api/trpc/[trpc]/ # tRPC API 路由
│ │ ├── (auth)/ # 认证相关页面
│ │ └── dashboard/ # 受保护的页面
│ ├── server/ # 后端逻辑
│ │ ├── api/ # tRPC 路由定义
│ │ │ ├── routers/
│ │ │ │ ├── user.ts
│ │ │ │ └── post.ts
│ │ │ └── root.ts
│ │ ├── auth.ts # 认证配置
│ │ └── db.ts # 数据库连接
│ ├── lib/ # 工具函数
│ │ ├── utils.ts
│ │ └── trpc.ts # tRPC 客户端配置
│ └── components/ # React 组件
├── prisma/
│ ├── schema.prisma
│ └── migrations/
└── public/
实战案例:构建一个博客系统
1. tRPC 路由定义
// src/server/api/routers/post.ts
import { z } from "zod";
import { createTRPCRouter, publicProcedure, protectedProcedure } from "../trpc";
import { createPostSchema } from "@/lib/schemas";
import { TRPCError } from "@trpc/server";
export const postRouter = createTRPCRouter({
// 获取文章列表(公开)
getAll: publicProcedure
.input(
z.object({
limit: z.number().min(1).max(100).default(10),
cursor: z.string().optional(),
published: z.boolean().default(true),
}),
)
.query(async ({ ctx, input }) => {
const posts = await ctx.prisma.post.findMany({
take: input.limit + 1,
cursor: input.cursor ? { id: input.cursor } : undefined,
where: { published: input.published },
include: { author: { select: { name: true, email: true } } },
orderBy: { createdAt: "desc" },
});
let nextCursor: string | undefined = undefined;
if (posts.length > input.limit) {
const nextItem = posts.pop();
nextCursor = nextItem!.id;
}
return { posts, nextCursor };
}),
// 获取单篇文章
getById: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const post = await ctx.prisma.post.findUnique({
where: { id: input.id },
include: { author: { select: { name: true, email: true } } },
});
if (!post) {
throw new TRPCError({
code: "NOT_FOUND",
message: "文章不存在",
});
}
return post;
}),
// 创建文章(需要认证)
create: protectedProcedure
.input(createPostSchema)
.mutation(async ({ ctx, input }) => {
return await ctx.prisma.post.create({
data: {
...input,
authorId: ctx.session.user.id,
},
});
}),
// 更新文章
update: protectedProcedure
.input(
z.object({
id: z.string(),
data: createPostSchema.partial(),
}),
)
.mutation(async ({ ctx, input }) => {
const post = await ctx.prisma.post.findUnique({
where: { id: input.id },
});
if (!post || post.authorId !== ctx.session.user.id) {
throw new TRPCError({
code: "FORBIDDEN",
message: "无权限操作",
});
}
return await ctx.prisma.post.update({
where: { id: input.id },
data: input.data,
});
}),
});
2. 前端文章列表组件
// components/PostList.tsx
import { trpc } from '@/lib/trpc'
export const PostList = () => {
const {
data,
isLoading,
error,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} =
trpc.post
.getAll.useInfiniteQuery(
{ limit: 10 },
{
getNextPageParam: lastPage => lastPage.nextCursor,
},
)
if (isLoading) return <div>加载中...</div>
if (error) return <div>错误: {error.message}</div>
return (
<div className="space-y-6">
{data?.
pages.map
((page, i) => (
<div key={i} className="space-y-4">
{
page.posts.map
(post => (
<article key={
post.id
} className="border rounded-lg p-6">
<h2 className="text-xl font-bold">{post.title}</h2>
<p className="text-gray-600 mt-2">{post.content}</p>
<div className="text-sm text-gray-500 mt-4">
作者: {
post.author.name
} | 发布时间:{' '}
{new Date(post.createdAt).toLocaleDateString()}
</div>
</article>
))}
</div>
))}
{hasNextPage && (
<button
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
className="w-full py-2 px-4 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50"
>
{isFetchingNextPage ? '加载中...' : '加载更多'}
</button>
)}
</div>
)
}
3. 创建文章表单(结合 Zod + React Hook Form)
// components/CreatePostForm.tsx
import { trpc } from '@/lib/trpc'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { createPostSchema, type CreatePostInput } from '@/lib/schemas'
export const CreatePostForm = () => {
const utils = trpc.useContext()
const createPost =
trpc.post
.create.useMutation({
onSuccess: () => {
// 重新获取文章列表
utils.post
.getAll.invalidate()
reset()
},
onError: error => {
console.error('创建失败:', error.message)
},
})
const {
register,
handleSubmit,
reset,
formState: { errors, isSubmitting },
} = useForm<CreatePostInput>({
resolver: zodResolver(createPostSchema),
})
const onSubmit = (data: CreatePostInput) => {
createPost.mutate(data)
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div>
<label className="block text-sm font-medium">标题</label>
<input
{...register('title')}
className="mt-1 block w-full border rounded-md px-3 py-2"
placeholder="请输入文章标题"
/>
{errors.title && (
<p className="text-red-500 text-sm mt-1">{errors.title.message}</p>
)}
</div>
<div>
<label className="block text-sm font-medium">内容</label>
<textarea
{...register('content')}
rows={10}
className="mt-1 block w-full border rounded-md px-3 py-2"
placeholder="请输入文章内容"
/>
{errors.content && (
<p className="text-red-500 text-sm mt-1">{errors.content.message}</p>
)}
</div>
<div className="flex items-center">
<input
{...register('published')}
type="checkbox"
className="mr-2"
/>
<label className="text-sm">立即发布</label>
</div>
<button
type="submit"
disabled={isSubmitting || createPost.isLoading}
className="w-full py-2 px-4 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50"
>
{isSubmitting || createPost.isLoading ? '发布中...' : '发布文章'}
</button>
</form>
)
}
性能优化最佳实践
1. 智能缓存策略
// lib/trpc.ts
import { httpBatchLink } from "@trpc/client";
import { createTRPCNext } from "@trpc/next";
import type { AppRouter } from "@/server/api/root";
export const trpc = createTRPCNext<AppRouter>({
config() {
return {
links: [
httpBatchLink({
url: "/api/trpc",
// 批量请求优化
maxBatchSize: 10,
}),
],
// React Query 配置
queryClientConfig: {
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 分钟
cacheTime: 10 * 60 * 1000, // 10 分钟
},
},
},
};
},
ssr: false, // 需要时可以开启 SSR
});
2. 数据预取(SSR/ISR)
// pages/posts/index.tsx
import { createServerSideHelpers } from "@trpc/react-query/server";
import { appRouter } from "@/server/api/root";
import { createTRPCContext } from "@/server/api/trpc";
export async function getStaticProps() {
const helpers = createServerSideHelpers({
router: appRouter,
ctx: await createTRPCContext({ req: null, res: null }),
});
// 预取数据
await helpers.post.getAll.prefetch({ limit: 10 });
return {
props: {
trpcState: helpers.dehydrate(),
},
revalidate: 60, // ISR
};
}
部署与运维
1. Docker 配置
# Dockerfile
FROM node:18-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production
FROM node:18-alpine AS builder
WORKDIR /app
COPY . .
COPY /app/node_modules ./node_modules
RUN npm run build
FROM node:18-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY /app/public ./public
COPY /app/.next/standalone ./
COPY /app/.next/static ./.next/static
EXPOSE 3000
CMD ["node", "server.js"]
2. 环境变量管理
# .env.local
DATABASE_URL="postgresql://user:
password@localhost:5432
/myapp"
NEXTAUTH_SECRET="your-secret-key"
NEXTAUTH_URL="
http://localhost:3000
"
GOOGLE_CLIENT_ID="your-google-client-id"
GOOGLE_CLIENT_SECRET="your-google-client-secret"
监控与错误处理
1. 错误边界
// components/TRPCErrorBoundary.tsx
import { TRPCClientError } from '@trpc/client'
import { ErrorBoundary } from 'react-error-boundary'
export const TRPCErrorBoundary = ({ children }: { children: React.ReactNode }) => {
return (
<ErrorBoundary
fallbackRender={({ error }) => {
if (error instanceof TRPCClientError) {
return <div>API 错误: {error.message}</div>
}
return <div>未知错误</div>
}}
>
{children}
</ErrorBoundary>
)
}
2. 日志记录中间件
// server/api/trpc.ts
import { t } from "./trpc-core";
const loggerMiddleware = t.middleware(async ({ path, type, next }) => {
const start = Date.now();
const result = await next();
const durationMs = Date.now() - start;
const meta = { path, type, durationMs };
if (result.ok) {
console.log("✅ OK request timing:", meta);
} else {
console.error("❌ Non-OK request timing", meta);
}
return result;
});
export const createTRPCContext = async ({ req, res }: CreateContextOptions) => {
console.log(`📡 tRPC Request: ${req?.method} ${req?.url}`);
return {
req,
res,
prisma,
session: await getServerAuthSession({ req, res }),
};
};
总结
Next.js + tRPC 这套技术栈,对独立开发者来说有几个非常关键的优势:
核心优势
- 端到端类型安全:从数据库到 UI 的完整类型保护
- 开发效率高:减少重复代码,把时间花在业务本身
- 现代化 DX:React Query、Prisma、NextAuth 等生态联动
- 渐进式采用:可以从小项目开始用 tRPC,一点点迁移
适用场景
- ✅ 个人项目和 MVP
- ✅ 小到中型团队的产品开发
- ✅ 需要快速迭代的创业项目
- ✅ 对类型安全和长期维护友好的项目
需要注意的点
- 学习曲线:需要对 TypeScript 和 React Query 有基本掌握
- 团队协作:前后端都最好使用 TypeScript,避免“类型断层”
- 项目规模:超大体量项目可能需要拆分为多服务 / 多 BFF
如果你正在寻找一个既现代又实战友好的全栈技术栈组合,Next.js + tRPC 非常值得一试。一次建好类型模型,就能在项目全生命周期里持续获益——真正做到 “一次建模,处处类型安全”。
Table of Contents
- 为什么 Next.js + tRPC 是独立开发的理想选择?
- 1. 端到端类型安全
- 2. 开发效率极高
- 3. 极佳的 DX(开发者体验)
- 技术栈整体架构
- 核心框架层
- 数据库 & ORM:Prisma + PostgreSQL
- 验证层:Zod
- 身份验证:NextAuth.js
- 推荐项目结构
- 实战案例:构建一个博客系统
- 1. tRPC 路由定义
- 2. 前端文章列表组件
- 3. 创建文章表单(结合 Zod + React Hook Form)
- 性能优化最佳实践
- 1. 智能缓存策略
- 2. 数据预取(SSR/ISR)
- 部署与运维
- 1. Docker 配置
- 2. 环境变量管理
- 监控与错误处理
- 1. 错误边界
- 2. 日志记录中间件
- 总结
- 核心优势
- 适用场景
- 需要注意的点