最近我把一个内部工具项目用 t3code 这套工程化方案重写了一遍,也就是大家说的 T3 Stack——Next.js + TypeScript + Tailwind CSS + tRPC + Prisma 的组合拳。老实说,写之前我对"端到端类型安全"这个口号是将信将疑的,真把业务跑通之后才理解,这东西对开发效率的提升完全是实打实的,不是玄学。
如果你是一个受够了"前端写接口联调、后端给文档、类型全靠猜"的全栈开发者,或者你是独立开发、小团队,想用一套技术栈把前后端一口气吃掉,那 t3code 这套方案值得你花一个下午认真过一遍。这篇文章我尽量不念文档,按照我当时从零搭项目的真实路径来写,包括为什么选它、核心代码怎么写、以及我在实际使用中踩过的坑和排查思路。
1. t3code 这个项目到底在做什么
1.1 T3 Stack 的组成与选型逻辑
先别被 T3 这个名字唬住,它不是什么高深框架,而是把几个当下 Web 开发里最顺手的工具按"全栈 + 类型安全"的逻辑组合起来。拆开看就是下面这张表:
| 组成部分 | 职责 | 解决什么问题 |
|---|---|---|
| Next.js | 应用框架 | 同时提供服务端渲染、路由、API 能力,一套代码搞定前后端 |
| TypeScript | 类型系统 | 让变量、函数、前后端数据结构都有显式类型 |
| Tailwind CSS | 样式方案 | 原子化 CSS,不用在类名和样式文件之间反复横跳 |
| tRPC | RPC 调用层 | 让前端像调用本地函数一样调用后端接口,全程类型自动推导 |
| Prisma | ORM 层 | 数据库建模、迁移、类型安全的查询客户端 |
有人会问,为什么一定是这五个?我的看法是:它们各自的选型逻辑非常一致——都是"开发者体验优先",且都原生支持 TypeScript。
比如后端接口这一层,传统方案是 Express/Fastify + REST 或 GraphQL,然后手动维护接口文档、生成类型声明。t3code 用 tRPC 直接省掉这步:你在服务端定义一个函数,客户端立刻获得对应的类型签名,连"再定义一遍 types"这一步都免了。数据库这块,Prisma 也提供了从 schema 定义到查询结果的完整类型链路,模型改了,前端代码的提示跟着变,不会出现"数据库字段改了你不知道"这种尴尬。
再比如样式,Tailwind 的原子类 + 服务端组件的组合,让页面代码里同时能看到结构和样式,不需要来回切文件。虽然这属于个人偏好,但对小团队和独立开发者来说,这种"尽量少上下文切换"的开发体验,确实是效率提升的关键。
1.2 类型安全带来的开发体验质变
我拿一个生活化的类比来解释什么叫"端到端类型安全":传统的接口联调像是两个国家之间靠外交信函沟通,信上写什么格式、需要什么签名,全靠约定,一旦约定没更新就会出岔子;t3code 更像你俩坐在同一张桌子前用同一种语言对话,你说"我要什么",对方直接按你的措辞回应,根本没有翻译损失。
具体落到代码上,你在后端定义了一个createPost函数,要求传入title: string和content: string,前端在调用时如果传了数字,编辑器直接标红,不用等运行时报错。数据库返回的对象结构也由 Prisma 推断,页面拿到数据后post.title能不能用、createdAt是什么类型,全部在编译期就锁死了。
这个项目适合谁?我认为最合适的是:全栈开发者、独立开发者、以及三五个人的小团队。因为这套方案把"后端路由定义、数据库模型、前端调用"全压在一个代码仓库里,成员少反而更好发挥。如果是大团队前后端分离、各有各的发布节奏,t3code 反而不一定能用上,因为 tRPC 强依赖前后端同时部署、共享代码。
2. 从零搭建 t3code 项目的完整实操
2.1 create-t3-app 初始化与选项取舍
t3code 最大的幸运是有官方脚手架,不需要手动把 Next.js、tRPC、Prisma 一个个拼起来。直接跑:
npm create t3-app@latest t3code它会以交互方式问你选哪些模块。我当时的选项和理由如下:
- Next.js App Router:选中。相比 Page Router,App Router 的 Server Component 能直接调用 tRPC,省掉一层"客户端先请求、再渲染"的等待,这个对体验影响很明显。
- TypeScript:必选,t3code 的核心逻辑就是这个,不选没法玩。
- Tailwind CSS:选中。如果你喜欢 CSS Modules 或 styled-components,可以去掉,但 Tailwind 在生成页面时确实快。
- tRPC:选中。这是 t3code 区别于普通 Next.js 项目的灵魂。
- Prisma:选中。数据库访问层,后面我写的所有例子都依赖它。
- NextAuth.js:看需求。如果项目不需要登录,可以先不选,后面要用再手动加也不麻烦。我当时要做一个带用户体系的内容管理工具,所以选了。
- Drizzle:如果 Prisma 和 Drizzle 同时出现,选 Prisma 即可,避免重复。
初始化结束后,npm run dev就能看到一个默认页面。这时候建议先不要急着改代码,花 10 分钟把生成目录扫一遍,理解它的结构再动手,后面会省很多事。
2.2 生成后的目录结构与数据流
脚手架会在你的项目里生成类似下面的结构:
src/ ├── app/ │ ├── api/ │ │ └── trpc/ │ │ └── [trpc]/route.ts │ ├── layout.tsx │ └── page.tsx ├── components/ ├── server/ │ ├── api/ │ │ ├── routers/ │ │ │ └── post.ts │ │ └── root.ts │ ├── auth.ts │ └── db.ts ├── trpc/ │ ├── react.tsx │ ├── server.ts │ └── shared.ts └── styles/第一次看这个结构可能会蒙,其实只要抓住一条主线——数据从数据库流向页面的路径:
src/server/db.ts创建 Prisma Client,负责数据库连接。src/server/api/routers/里定义业务逻辑(比如postRouter)。src/server/api/root.ts把各个子路由汇总成 appRouter。src/app/api/trpc/[trpc]/route.ts是 HTTP 入口,把 tRPC 请求接入 Next.js。- 前端通过
src/trpc/react.tsx或src/trpc/server.ts提供的 API 调用这些路由。
这个结构的好处是:无论你之后加多少个业务模块,都只需要遵循"新增长文件到 routers 目录、挂载到 root"这一个模式,不需要设计新的架构规则。我当时第一个功能就是这么加出来的:写一个 post 路由,挂到 root,页面里直接调用,半小时打通全链路。
3. 核心功能实现:从数据库到页面的完整链路
3.1 Prisma 数据模型与迁移
我从一个最简单的"文章发布"功能入手。先在prisma/schema.prisma里定义一个 Post 模型:
generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } model Post { id Int @id @default(autoincrement()) title String content String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }模型定义好之后,选择迁移方式。t3code 的坑点在于配置里给了两种用法,很多人上来就prisma db push,结果一改模型就丢数据。我的建议是:
npx prisma migrate dev --name initmigrate dev会生成迁移记录文件,之后每次改模型都跑一条新的迁移,团队协作时数据结构的变更历史一目了然。db push更像临时改表,适合开发初期试字段,不适合正式流程。
迁移完成后,在代码里使用 Prisma Client。脚手架里的src/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; }这个模式能避免开发模式下 Prisma Client 实例被反复创建,导致连接数爆掉。
3.2 tRPC 路由的写法和校验
有了 Prisma 模型,接下来就是 tRPC 路由。在src/server/api/routers/post.ts里写:
import { createTRPCRouter, protectedProcedure, publicProcedure } from "~/server/api/trpc"; import { z } from "zod"; export const postRouter = createTRPCRouter({ list: publicProcedure .query(async ({ ctx }) => { return ctx.db.post.findMany({ orderBy: { createdAt: "desc" }, }); }), create: protectedProcedure .input(z.object({ title: z.string().min(1).max(100), content: z.string().min(1), })) .mutation(async ({ ctx, input }) => { return ctx.db.post.create({ data: { title: input.title, content: input.content, createdById: ctx.session.user.id, }, }); }), });这里有两个核心点值得细说。
第一,publicProcedure 和 protectedProcedure 的区别。前者不需要登录就能访问,后者会先检查 Session 是否存在,不存在直接抛错。这个是在脚手架配置 NextAuth 时自动接好的,你不需要在路由里手动判断用户是否有登录权限,类型系统已经把上下文里的session给到了。
第二,zod 校验的威力。z.object({ title: z.string().min(1).max(100) })这段不只是运行时校验,它同时承担了 TypeScript 类型推断的职责。前端调用createPost时,如果 title 传了一个数字或者超过 100 字符,编辑器里立刻会有提示,请求根本发不出去。
写完子路由后,别忘了在src/server/api/root.ts里挂载:
import { postRouter } from "~/server/api/routers/post"; export const appRouter = createTRPCRouter({ post: postRouter, }); export type AppRouter = typeof appRouter;这一步是很多人 404 的根源,后面我会专门讲排查思路。
3.3 页面上的调用方式:把类型的魔法落实到页面
tRPC 提供了两套调用方式:服务端组件里直接调用函数,客户端组件里走 React Query hooks。两者各有使用场景,我分别演示。
服务端组件调用(适合首屏渲染):
// app/page.tsx import { api } from "~/trpc/server"; export default async function HomePage() { const posts = await api.post.list(); return ( <main className="mx-auto max-w-2xl p-8"> <h1 className="text-2xl font-bold">最新文章</h1> <ul className="mt-4 space-y-2"> {posts.map((post) => ( <li key={post.id} className="rounded border p-3"> <h2 className="font-semibold">{post.title}</h2> <p className="text-gray-600">{post.content}</p> </li> ))} </ul> </main> ); }注意,api.post.list()作为一个 async 函数直接 await,不需要在客户端发请求。这个场景非常适合 SEO 和首屏速度要求高的页面。
客户端组件调用(适合交互型页面,比如列表后面的"加载更多"、评论区):
"use client"; import { api } from "~/trpc/react"; function PostList() { const { data, isLoading, error } = api.post.list.useQuery(); if (isLoading) return <div>加载中...</div>; if (error) return <div>加载失败:{error.message}</div>; return ( <div> {data?.map((post) => ( <div key={post.id}>{post.title}</div> ))} </div> ); }只要在页面应用中安装过<TRPCReactProvider>(脚手架会在layout.tsx里配好),这些 hooks 直接就能用。我个人的体会是:首页、详情页这些偏展示的用服务端调用,需要缓存、自动重试、监听状态的交互区用客户端 hooks,两者配合下来,开发效率非常舒服。
4. 实战中踩过的坑与排查清单
4.1 类型老是标红?先分清三类问题
我在用 t3code 过程中,90% 的 TypeScript 报错其实是三个原因造成的,全部排查过之后,基本就没有莫名其妙红了:
- Prisma Client 没有重新生成。每当你改了
schema.prisma,必须执行npx prisma generate。模型的新字段不会自动出现在类型定义里,忘了生成就操代码,编辑器一定标红。 - 环境变量缺失。
DATABASE_URL、NEXTAUTH_SECRET没有配好,会导致启动阶段直接报错,而且错误提示比较隐晦。建议新建一个.env文件,参考脚手架的.env.example,把每一项都填全。 - zod 输入和数据库字段类型对不上。比如前端传的是 string 的 id,数据库是 Int,tRPC 的类型推导会把不一致暴露在编译阶段。解决办法是统一在 zod 里做
z.number()或转换。
遇到标红不用慌,先执行npx tsc --noEmit看完整错误列表,比 IDE 里一个个点效率高得多。
4.2 tRPC 路由 404 或请求超时怎么查
这个是我初期踩得最深的一个坑,现象是:页面加载正常,但一调用某个 procedure 就 404 或转圈半天。按下面的顺序查基本能定位:
- 确认子路由挂载了。在
root.ts引入并在createTRPCRouter里注册。忘了挂载的子路由在客户端类型里根本不存在,IDE 会提示,但如果用字符串调用(少数非类型调用场景),就会 404。 - 确认
route.ts存在。src/app/api/trpc/[trpc]/route.ts是 HTTP 入口,如果它不存在或被误删了,所有请求都会 404。 - 确认运行环境配置正确。比如在服务端组件里调用 tRPC 时,如果
NEXTAUTH_URL配置不对,中间件的 Session 检查会导致请求挂起,表现为超时而不是快速报错。 - 检查 URL 前缀。如果你部署在子路径下,比如
https://site.com/blog,需要在trpc/server.ts和trpc/react.tsx里配置url: "/blog/api/trpc",否则路径对不上。
4.3 Prisma 连接被拒和环境切换问题
开发环境一切正常,部署到生产环境就报数据库连不上,这个问题的根源往往不是密码错,而是连接串的作用域不对。例如:
- 本地
.env里的DATABASE_URL用的是localhost:5432,部署到线上还是这个串,远程数据库自然拒连。 - 有些云数据库要求 SSL,但连接串没加
?sslmode=require,Prisma 会一直握手失败。
我的做法是:本地数据库用 Docker 起一个 PostgreSQL 容器,项目里维护三份环境文件.env(本地)、.env.production(生产),各写各的连接串,绝不混用。另外,生产环境不要直接暴露数据库端口,尽量让应用和数据库处在同一个内网。
4.4 部署到 Vercel 前必须检查的项目
t3code 对 Vercel 的适配非常友好,Next.js 和 Vercel 本来就是一家人,但有几个检查项我每次部署都会过一遍:
- Prisma generate 要跑在构建阶段。Vercel 的构建脚本里需要包含
npx prisma generate,否则 Serverless 环境里找不到 Prisma Client。可以在package.json的postinstall里加。 - 环境变量重新填一遍。Vercel 里把
DATABASE_URL、NEXTAUTH_SECRET、NEXTAUTH_URL全部重新配置,不要依赖本地.env。 - Serverless 函数的执行时长限制。Vercel 默认函数超时时间是 10 秒(Hobby 计划),如果用 Prisma 做复杂查询,可能在冷启动时超时。优化的办法是减少查询条数、加索引,或者把冷启动必经的查询缓存起来。
- 数据库连接数。免费版 PostgreSQL 数据库的连接池通常很小,而 Serverless 环境多个冷启动实例会同时建连。建议开一个 PgBouncer 之类的连接池,或者在 Prisma 连接串里配置
connection_limit=5之类的参数。
这几个点如果都过了,t3code 项目部署基本是一次成。我自己第一次部署时就是忘了postinstall里的 prisma generate,结果构建成功但运行时疯狂报错,排查了快一小时才反应过来。
5. 写在最后的一点体会
聊了这么多,最后说点我个人真实的使用感受。现在我手头几个内部项目都迁移到了 t3code 这套方案上,最直观的变化是接口文档基本不写了——tRPC 的类型签名本身就是文档,前端调用时鼠标一悬停就能看到参数和返回值结构,比看任何文档都直观。数据库模型变更后,前后端会同步出现类型漂移,这种"改一处、全链路报错提醒"的体验,在传统架构里真的很难实现。
当然它也不是银弹。如果团队规模大、前后端独立部署、接口需要给第三方用,tRPC 的紧耦合反而会成为束缚。但如果你是个人开发者或三五人的小团队,想用一个下午把全栈效率提上一个台阶,t3code 这套组合拳非常值得试。项目里如果后面要加定时任务、事件队列这类基础能力,我建议先在 tRPC 前面做一层薄薄的适配层,避免业务逻辑被框架锁死。就这些,希望能给正在评估这套方案的朋友提供点实际参考。