先聊一个很多做全栈开发的朋友都会遇到的场景:技术选型时,到底是用 Next.js 配 REST API,还是用 NestJS 加前端框架,或者干脆走微服务。每个人都有一堆理由,但真正拉通前后端、把类型安全做到极致的方案并不多。t3code 这个名字,乍一看就是个普通的模板仓库,但它背后其实是一整套基于 T3 Stack(Next.js + TypeScript + tRPC + Prisma + Tailwind)的现代全栈实践样板。即使你不直接复制这套代码,照着它的思路去搭项目,也能少踩很多坑。
这篇文章我会把它背后的技术选型、目录结构、核心链路、常踩的坑、以及部署时的注意点完整拆一遍。不管你是刚接触全栈 TypeScript 的新手,还是在评估是不是该把公司老项目迁移到这套技术栈,这篇都能当作一份“过来人”的参考。
1. 整体设计与思路拆解:为什么是 T3 Stack
1.1 不要急着挑框架,先想清楚要解决什么问题
t3code 这套项目给我的第一观感是:作者没有追新潮,而是把“端到端类型安全”这个核心诉求放到了第一位。提到 T3,可能有人会想到“Third Generation”,但在前端圈子里,更常见的解释是“The T3 Stack”,它的创始人在概念上很拧巴地强调过,T3 不是一个官方框架,而是一种构建全栈应用的思路。
它选型就三句话:
- 需要服务端渲染和文件路由,那就选 Next.js,而不是再单独起一个前端工程。
- 需要快速迭代和易维护的样式体系,那就用 Tailwind,配合设计系统非常顺手。
- 需要 API 层,与其自己定义一堆 OpenAPI 文档、再同步生成类型,不如直接上 tRPC,让客户端和服务器共享一套 TypeScript 类型。
这个思路看着简单,但它是从实际痛点出发的。很多中大型项目里,前端调后端接口时,经常出现字段对不上、类型断不准、改后端字段后前端直接编译报错而不是运行时才发现这类问题。tRPC 就是在这一层帮你把类型打通:你只需要在服务端写一个 procedure,客户端直接 import 同名函数调用,类型就像“穿透”过去一样。
1.2 t3code 的目录和模块划分逻辑
如果你 clone 过 t3code 这类项目,会发现它的目录结构非常有辨识度:
project-root/ ├─ app/ # Next.js App Router 目录 │ ├─ api/trpc/ # tRPC http handler 挂载点 │ ├─ _components/ # 页面级组件、服务端组件 │ └─ layout.tsx ├─ server/ │ ├─ api/routers/ # tRPC 路由分组 │ └─ db.ts # Prisma Client 单例 ├─ prisma/ │ ├─ schema.prisma │ └─ seed.ts └─ trpc/ ├─ client.ts └─ server.ts这种拆法有个明显好处:tRPC 路由、数据库模型、页面组件三者之间彼此独立。运行时只要依赖注入做得好,前端页面改版不会牵扯到 API 层。
app/里只放页面级别的样板,组件保持小粒度。server/下面按“路由域”拆文件,而不是按技术角色拆文件。比如订单相关逻辑,就把查询、变更、状态机都集中到一个 router 里。prisma/放 schema 和种子数据脚本,方便本地初始化。
从工程化角度看,这个分层其实是一种“约定优于配置”的思路。对于一个中小规模团队,最怕的是没有约定、每个人创建出自己的“私有目录”,最后代码里出现七八种目录风格。t3code 这种结构虽然看起来约束多,但反而让新人上手更快。
1.3 为什么不用 GraphQL 或传统 REST
我在做技术评估时,常常被问到为什么不选 GraphQL。这里说句大实话:GraphQL 解决的是“客户端按需选择字段”的问题,但它需要额外的 schema 定义、resolver 维护、还有权限处理的复杂度。如果项目就那么几十个接口,大部分还是表单提交和数据列表,GraphQL 等于给项目上了一个很重的保险杠。
REST 就更不用说了,它最大的问题不是能力不足,而是类型无法自动串联。哪怕你用 OpenAPI 生成客户端 SDK,也得有额外工具链,并且版本同步容易出问题。tRPC 的方式本质上就是“直接调用服务端函数”,不需要写 URL 映射、不需要定义请求参数的类型别名,所有的 type 都在一个编译上下文里。代价就是你基本只能服务 TypeScript 客户端,但对你自己的全栈项目来说,这根本不是问题。
t3code 最聪明的点在于把 tRPC 和 Next.js 的 Server Component 结合。服务端页面可以直接通过 server client 来调用 procedure,不再需要二次 fetch 自己的 API,省掉了两次网络往返。这一步在小项目里看不出什么,但数据量大以后,减少请求链路的收益非常直接。
2. 初始化与核心配置:我如何从零复现一套 t3code 项目
2.1 环境准备与 create-t3-app 命令
如果你不想从零搭 Webpack 配置、手动集成 ESLint 和 Prettier,最省力的方式是直接用官方脚手架:
npm create t3-app@latest my-t3-app执行完后,CLI 会问你几个问题:
- TypeScript 还是 JavaScript?这个项目要的就是类型穿透,所以必须选 TypeScript。
- 是否启用 Next.js App Router?现在默认就是 App Router,尽量别选旧模式。
- 是否启用 tRPC、Prisma、Tailwind、NextAuth?建议全选。t3code 项目本质就是这四件套的组合。
- 是否启用 ESLint、Prettier?必选,代码规范化靠这个。
装完以后你会得到一个可以直接出现在浏览器里的项目。此时先别急着写业务,第一步是跑通数据库连接。脚手架默认用的是 SQLite,因为本地开发最省事。正式部署你可以改成 PostgreSQL 或 MySQL,只需要改 Prisma schema 里的 datasource provider。
提示:我试过很多次,本地初始化时如果 Node 版本低于 18 ,容易遇到 Next.js 的 runtime 兼容报错。至少用 Node 20 LTS 版本,后续也用得放心。
2.2 Prisma Schema 设计里的关键细节
t3code 项目的 schema.prisma 里,最典型的是一个多对多关系模型。比如说“用户-项目-标签”这种业务结构,你要在 Prisma 里表达,通常会写两个 model 和一个中间表:
model User { id String @id @default(cuid()) name String? email String @unique projects Project[] @relation("UserProjects") } model Project { id String @id @default(cuid()) name String users User[] @relation("UserProjects") tags Tag[] @relation("ProjectTags") createdAt DateTime @default(now()) } model Tag { id String @id @default(cuid()) label String @unique projects Project[] @relation("ProjectTags") }这里建议把外键字段名显式写出来,不要全靠 Prisma 自动生成。比如中间表如果有额外字段(角色、权重、创建时间),自动生成的关系是表达不了的。多对多关系如果需要携带属性,更好的方案是显式定义中间表:
model ProjectUser { userId String projectId String role String? // 比如 owner/member/viewer assignedAt DateTime @default(now()) user User @relation(fields: [userId], references: [id]) project Project @relation(fields: [projectId], references: [id]) @@id([userId, projectId]) }这种写法我第一次用的时候觉得多此一举——直接用隐式关系不是更省事吗?后来踩到坑才明白:一旦某天要加“项目成员角色”这种属性,隐式关系就必须重新迁移、拆表,改动面很大。所以设计先行,把 schema 文档化,比后续迁移成本低得多。
另外强烈建议开启 Prisma 的prisma migrate dev工作流。不要手动去建表,每次改动 schema 后,一定要执行:
npx prisma migrate dev --name init npx prisma generate第一条命令会生成 SQL 迁移文件并同步数据库;第二条命令重新生成 Client 类型。如果只改了 schema 忘了 generate,IDE 里会莫名其妙报类型错误,排查半天才发现是 client 没更新。
2.3 tRPC Router 的定义与上下文传递
tRPC 的模板在 t3code 里通常分成initTRPC、createContext和router三层:
// server/trpc.ts import { initTRPC } from '@trpc/server'; import { type CreateNextContextOptions } from '@trpc/server/adapters/next'; const createContext = ({ req, res }: CreateNextContextOptions) => { const user = getServerSession(req, res); // NextAuth 获取会话 return { user, db }; }; type Context = Awaited<ReturnType<typeof createContext>>; const t = initTRPC.context<Context>().create(); export const router = t.router; export const publicProcedure = t.procedure;这里有个容易被忽略的点:context 里的 db 实例必须是全局单例。如果在每个请求里都 new PrismaClient,会导致连接池爆掉。所以 t3code 一般会在server/db.ts里做:
import { PrismaClient } from '@prisma/client'; const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }; export const db = globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db;这种做法其实是从 Next.js 官网推荐的“防止开发环境热重载时创建多个连接”里演化出来的。本地开发热更新比较频繁,如果不做 global 缓存,控制台会刷出一堆连接警告。
createContext 的另一个作用是做鉴权。你可以在 procedure 中间件里判断 context.user,也可以在定义 router 时单独设置一个 protectedProcedure:
const protectedProcedure = t.procedure.use(({ ctx, next }) => { if (!ctx.user) throw new TRPCError({ code: 'UNAUTHORIZED' }); return next({ ctx: { ...ctx, user: ctx.user } }); });这个写法的好处在于,只有需要登录的请求才会走到鉴权逻辑,公开接口不受影响。而且一旦鉴权失败,抛出的错误会通过 tRPC 链路统一返回给客户端,不用每个 handler 里手动判断。
3. 核心业务逻辑与调用链路:从页面到数据库的完整往返
3.1 服务端调用方式:Server Client 直连
单独跑 tRPC 并不稀奇,但 t3code 这类项目更推荐在服务端组件里调用 procedure。这样你看代码时,可以像调用本地模块函数一样取数:
// app/projects/page.tsx import { serverClient } from '@/trpc/server'; export default async function ProjectsPage() { const projects = await serverClient.project.getAll(); return ( <main> {projects.map((p) => ( <div key={p.id}>{p.name}</div> ))} </main> ); }serverClient内部通过createCaller创建,它会直接走 tRPC 的执行链,不需要网络请求。这么做有几层好处:
- 不会产生浏览器到服务器的额外 API 请求。
- 服务端渲染时,数据已经存在,首屏也不会有 loading 闪烁。
- 类型是同一套,不会出现“接口返回 snake_case,前端要用 camelCase”这种手工转换。
你可能会想,如果页面里同时要传 query 参数怎么办?那就把 searchParams 传给 procedure 的 input:
const projects = await serverClient.project.getList({ page: searchParams.page ?? 1, pageSize: 10, });注意这里的 input 类型也是自动校验的,如果你的 input 用了 zod,那合法性检查在服务端执行。比如你规定 page 必须正整数,前端传了 0,tRPC 直接抛 400,错误清晰且定位快。
3.2 客户端交互:React Query 与 mutation 的组合
到了需要用户触发的操作(比如提交表单、删除记录),就轮到客户端调用。t3code 集成了 @tanstack/react-query 作为缓存和请求管理层,并且把 React Query 的 devtools 一并配好,本地调试非常舒服:
const utils = api.useUtils(); const createProject = api.project.create.useMutation({ onSuccess: async () => { // 让项目列表自动刷新 await utils.project.getAll.invalidate(); }, }); function handleSubmit() { createProject.mutate({ name: inputName }); }这种方法与手写 fetch 相比,最明显的优势是:你不需要自己写 loading 状态、错误状态和重新请求逻辑。React Query 帮你管好了。如果页面里有多个接口依赖同一个数据集,调用invalidate就能把相关查询全部标记为 stale,从而自动 refetch。
我在自己的项目里实测过,对一个包含三个枚举筛选条件的列表,用 tRPC 的 query 缓存做二次筛选,即使 filter 条件变化不需要重新请求,也能从缓存里直接读出来。省了后端流量,交互也很顺滑。
不过这里有个坑要特别注意:不要在客户端不必要地暴露大对象列表。比如用户列表里有 hash 值或者内部字段,tRPC 的输出类型和数据库类型是同一个,你必须显式定义 output 校验器来截断字段。我的习惯是每个 router 都写一个select对象,只在查询时挑选必要的列,避免一次返回几十个字段。
3.3 NextAuth 与 tRPC 的鉴权集成
登录这块 t3code 通常默认集成 NextAuth。它的优雅之处在于,session 里面只存必要的用户标识,然后通过 context 把它带入 tRPC。
NextAuth 的完整配置一般长这样:
export const authOptions: NextAuthOptions = { providers: [ GoogleProvider({ clientId: process.env.GOOGLE_CLIENT_ID!, clientSecret: process.env.GOOGLE_CLIENT_SECRET!, }), ], callbacks: { async session({ session, token }) { if (session.user) { session.user.id = token.sub!; } return session; }, }, };有一点容易被忽略:session 里默认 user.id 是不存在的。所以需要一个 callback 把 token 里的 sub(也就是用户 ID)映射到 session.user.id 上。否则前端拿不到用户 ID,很多需要按用户过滤的查询就没法做。
实际开发中,我习惯在 NextAuth 的authorized回调里再查一次数据库,确认用户仍然有效。如果不做这个校验,用户被禁用后仍然能访问受保护页面,一旦遇到就是安全事故。
4. 常见问题与排查技巧实录
4.1 tRPC 请求 404:路由挂载前缀不匹配
这个报错太常见了。t3code 里默认的 tRPC endpoint 是/api/trpc。如果前端 client 的 url 配错,或者 Next.js 的 rewrite 把它拦截掉了,就会出现客户端能编译、但请求一发出去就 404。
排查思路:
- 检查
trpc/client.ts里的 url 是否和app/api/trpc/[trpc]/route.ts里的路径一致。 - 在 Next.js 12 到 13 升级时,API Routes 从
pages/api迁移到app/api,容易漏掉动态段[trpc]。 - 查看服务端日志,确认是否有请求进入 handler。
这个坑在多人协作的项目里特别典型:前端同学改过 proxy 配置后就再也调不通了。建议在 client 初始化时把 url 提取为环境变量,比如
NEXT_PUBLIC_TRPC_API_URL,而不是写死字符串。
4.2 Prisma 同步问题:表结构变了,类型却没有
开发中我改过好多次 schema,每次都会遇到 IDE 里还在报“字段不存在”的情况。这多半是因为 TypeScript 类型来自@prisma/client的生成文件,而不是直接扫描 schema.prisma。只要忘记执行npx prisma generate,前端就会用旧的类型定义去访问新字段,自然会报红。
有一个小技巧,在 package.json 里加一个 postinstall 脚本:
"postinstall": "prisma generate"这样每次 install 之后都会自动生成最新的 Client,从根上解决了“忘了 generate”的问题。
4.3 tRPC 错误在客户端显示为 “Unexpected token u in JSON”
这个报错我当初排查了很久。它通常不是语法问题,而是服务端抛出的 Error 对象无法序列化。比如在 procedure 里throw new Error('fine')可能没问题,但如果 Error 里有非 JSON 字符的字段、包含函数或循环引用,传输时就会出问题。
标准做法是用 TRPCError:
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: '生成报告失败', cause: originalError, });这样错误就会被规格化后返回客户端,并且 React Query 能把它解析为类似TRPCClientError的实例,前端可以直接读取error.message展示给用户。
有一次我遇到的是 Prisma 唯一约束冲突,用普通的ajv校验也能过,但写库时报错。当时我没有对唯一性做预检查,导致 tRPC 把所有内部错误都当成 500 返回。后来在 mutation 服务端加了catch分支,检测 P2002 错误码并转成 409 冲突提示,体验才正常。
try { await db.tag.create({ data: { label } }); } catch (e) { if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { throw new TRPCError({ code: 'CONFLICT', message: '标签已存在' }); } throw e; }4.4 部署后环境变量缺失导致 Prisma Client 初始化失败
本地开发时,.env文件都在根目录,数据库连接字符串和 NextAuth 密钥都顺手可读。但部署到服务器或 Serverless 平台时,环境变量经常没有同步过去。Prisma Client 在初始化时要通过DATABASE_URL连接数据库,缺失时不会等请求报错,而是服务启动阶段直接挂掉。
建议在 CI/CD 时增加一个检查环节:
- 确认
DATABASE_URL已注入。 - 如果用了 PostgreSQL,记得加上
sslmode=require(云数据库场景下)。 - NextAuth 的
NEXTAUTH_SECRET如果缺失,SSO 登录会一直报 jwt 校验失败。
我比较推荐在项目的src/env.mjs里做运行时校验,用 t3 模板自带的环境变量 schema 来限定哪些变量必须存在。这样启动时就能立刻暴露问题,而不是在用户访问时才看到白屏。
4.5 表单校验与 zod:把“输入地狱”变成“一行式规则”
t3code 里的全套表单校验基于 zod,前后端共用同一套 schema。tRPC 的 input 定义通常会复用从 zod 导出的对象:
const createProjectInput = z.object({ name: z.string().min(1, '项目名称必填').max(20, '项目名称太长'), description: z.string().optional(), }); const create = protectedProcedure .input(createProjectInput) .mutation(async ({ ctx, input }) => { return ctx.db.project.create({ data: { name: input.name, description: input.description }, }); });如果你用过express-validator或者手写 if 校验,会明显感觉到 zod 的体验差距。尤其当你在多个接口里复用一个对象 schema 时,定义一次就好,前端还能通过z.infer拿到对应的 TypeScript 类型。比如上面这个 schema 就能自动推导出{ name: string; description?: string }。
我在实际项目中还遇到过用户输入空字符串的问题,解决办法是在 zod string 上链式调用.trim():
z.string().trim().min(1, '不能为空')如果不 trim,用户敲几个空格也能提交,前端 min(1) 也会误判为合法。
5. 性能优化与生产环境实战
5.1 列表查询避免 N+1
t3code 这种全栈项目往往在关系数据上用力,所以 Prisma 查询里最容易出现 N+1 问题。最简单的解决方法是使用 include:
const projects = await db.project.findMany({ include: { tags: true, members: true }, });但 include 会把所有关联数据一次性捞上来,如果仅为了显示标签名,就会白白查询很多字段。更精准的做法是用select:
const projects = await db.project.findMany({ select: { id: true, name: true, tags: { select: { label: true } }, }, });在 t3code 这类模板里,一定要形成习惯:默认收缩返回字段,只 select 当前页面需要的列。别嫌麻烦,数据量的增长往往比想象中快,一个列表页后端返回 80 个字段,前端只用 5 个,这会造成不必要的网络与序列化开销。
5.2 使用 React Query 的预取提升导航速度
在 App Router 模式下,既可以用 Server Component 直接拿数据,也可以在客户端用 tRPC query。如果想进一步优化体验,可以把首屏数据预取进 React Query 缓存:
const trpcClient = api.useUtils(); await trpcClient.project.getAll.prefetch();这样用户在点击某个按钮跳转到详情时,页面能直接读缓存,不需要再等请求。
我自己的项目里曾有一个列表页,每次路由切换时都出现半秒钟白屏。后来把所有查询 move 到父布局预取,白屏变成了“一抹即逝”的动画过渡,体感提升很明显。
5.3 自动部署与 CI:小团队也能一人运维
t3code 这类项目最适合部署到 Vercel,其次是自行购买的云主机。如果你用 Vercel,只需要在项目配置里指定好环境变量,push 到仓库就能自动部署。有一点要注意:如果使用 SQLite,数据库文件在 Serverless 环境里没法持久化,必须换成外部数据库。如果使用 PostgreSQL 或 MySQL,记得把迁移脚本放到构建阶段执行。
参考 Vercel 的构建命令:
npx prisma migrate deploy && npm run build不建议在构建时使用prisma migrate dev,因为 dev 模式会自动生成模拟数据,生产环境会干扰真实表结构。用migrate deploy只会执行迁移文件里既有的变更,更安全。
5.4 日志与错误监控:保留错误追踪链路
即使代码写得再严谨,生产环境还是会出意外。t3code 模板没提供现成的日志系统,但你可以很快接入 Sentry 或者基于 OpenTelemetry 的工具。重点是给 tRPC procedure 统一包一层 error reporting:
export const protectedProcedure = t.procedure .use(loggingMiddleware) .use(authMiddleware);在中间件里把输入、用户、耗时、错误码都打点,输出到 stdout。Serverless 环境下,我倾向于直接聚合到日志平台,用 traceId 串起整条请求链。
6. 写在最后:我的一点实操体会
坦白讲,t3code 这类项目并不是银弹。它最舒服的场景是中小型全栈应用:几个人、一个仓库、一份类型定义,从数据库到前端一路畅通。如果你的团队有成规模的 B 端系统,需要独立的服务集群或者对外开放 API,那还是要评估一下边界。
我实际使用中最大的体会是,类型穿透带来的安全感会改变写代码的习惯。以前我会花很多时间在“接口文档字段对齐”上,现在写服务端 model 时就会下意识考虑前端怎么用它,反过来在前端写 UI 时也能顺手点进类型定义看数据库里有哪些可用字段。哪怕不做严格 DDD,这种“一个模型贯穿前后端”的开发方式,也能让团队沟通成本下降一截。
最后再分享一个小技巧:t3code 这种模板项目,不要一下来就改核心结构,先按原样跑通一个极简业务闭环(建表、写 procedure、页面调用、缓存刷新)。跑通一次之后,再根据自己的项目重命名模块、拆分 router,会顺畅很多。如果哪天你又看到一个叫 t3 或者类似的全栈模板,也可以拿这套方法论快速判断它值不值得用:先看类型是否端到端、再看迁移和部署成本,最后看生态兼容性。特别是类型安全这一点,能做好,项目就成功了一半。