t3code这个名字,我第一次看到的时候,第一反应是某个小工具或者个人项目的代号。但等我把整个上下文捋清楚,发现它指向的其实是当下TypeScript全栈开发里一个非常值得聊的实践方向——围绕T3技术栈去组织代码、搭建项目、落地上线。市面上聊T3 Stack的文章不少,但大多是介绍性的,告诉你"这是个好东西",却很少告诉你"用起来到底有哪些坑"。
这篇文章我换个角度,不跟你掰扯概念,直接从我实际做过的项目出发,讲讲我用T3这套思路搭建一个完整应用时的选型逻辑、代码组织方式、以及那些文档里根本不会写的细节。代码、配置、踩坑记录都在,你照着走一遍,基本能避开我当初踩过的所有泥坑。
1. 我为什么盯上T3这套技术栈组织代码
先说结论:T3不是一个新的框架,而是一套项目组织思路的组合拳。它的核心是TypeScript 优先,全链路类型安全。
我最早做全栈项目,用的是Next.js + 自己手写的REST API + 单独的数据库层。那会儿项目规模不大,问题还不明显。但一旦业务复杂起来,前后端联调就成了灾难——后端改了个字段名,前端要翻半天代码,跑起来才发现类型对不上。类型安全这种东西,小项目里你觉得它烦,等项目过了几万行代码,它就是你的救命稻草。
T3这套组合,我理解下来就三层:
- 表现层用 Next.js。图的就是它的文件路由和混合渲染(服务端组件、客户端组件、API Route随便混用),但更重要的是它和下面两层能无缝衔接。
- API 层用 tRPC。这是整个组合的灵魂——它让前端调用后端函数就像调用本地函数一样,而且类型是自动从后端推导到前端的。后端加一个字段,前端立刻就能在代码提示里看到,联调成本直接被砍掉一大截。
- 数据层用 Prisma + SQL 数据库。关系型数据库还是最稳的选择,Prisma 负责把数据库表映射成 TypeScript 类型,这层类型又能直接喂给 tRPC,链路就全通了。
额外解决前端样式那部分,T3惯例是搭配 Tailwind CSS。这倒不是必须,但它和T3的"快速、简洁、默认有一致性"的理念挺配的。
我用这套思路实际做完一个中等规模的项目之后,最大的感受是:你不再需要花时间维护接口文档,因为类型本身就是文档;你也不再需要为了一个字段来回问后端同事,因为接口签名的变化在前端代码里已经呼之欲出。
当然,T3不是银弹。它适合那种前后端都由一个人或一个小团队主导的项目。如果你们公司前后端团队完全隔离、接口都需要对外部系统开放,那REST + OpenAPI可能更合适。这个判断我后面细说。
2. 从零搭建一个t3code项目的完整过程
初始化这一步,网上教程千篇一律地让你跑create-t3-app,但跑完之后那一堆文件到底是怎么组织起来的,很多人一知半解。我带你过一遍,并解释每一步为什么要这么干。
2.1 环境准备:比想象中更容易踩坑的三件事
先看我用的环境版本,你们照着排查会方便很多:
| 工具 | 版本 | 说明 |
|---|---|---|
| Node.js | 18.17+ 或 20.x | 低于这个版本跑 Next.js 14 会有兼容性问题 |
| pnpm | 8.x / 9.x | 推荐用 pnpm,磁盘占用小,依赖安装快 |
| PostgreSQL | 14+ 或 15+ | T3 默认数据库是 Postgres,SQLite 只适合本地快速验证 |
| Docker | 可选 | 本地起数据库最省事的方式 |
这里有个非常常见的问题:很多人图省事直接在本地装 PostgreSQL,但自己不太熟悉数据库运维,导致后面连接串配错。我的建议是,如果只是开发调试,直接用 Docker 起一个:
docker run --name t3-dev-db -e POSTGRES_USER=t3user -e POSTGRES_PASSWORD=t3pass -e POSTGRES_DB=t3code -p 5432:5432 -d postgres:15这条命令起的容器暴露了 5432 端口,数据库名、用户名、密码都是显式的,后面写环境变量的时候直接填进去就行。等你不需要了,docker stop t3-dev-db && docker rm t3-dev-db一删即可,环境干净得很。
2.2 初始化命令的选择与说明
初始化项目建议直接跑命令,没必要从零手写配置文件:
pnpm create t3-app@latest它不像一些脚手架那么粗暴,会逐步问你几个问题,关键是这俩:
- TypeScript 还是 JavaScript?选 TypeScript。T3 的核心优势就是 TS 的全链路类型推导,你选 JS 等于自废武功。
- 项目类型选Next.js就好,后续 App Router 和 Pages Router 的差异,新版默认走 App Router,但我个人在 T3 项目里用过两种,后面会对比。
我本地跑完看到的目录大概是这样的:
prisma/ schema.prisma src/ components/ pages/ 或 app/ server/ api/ # tRPC 路由定义 auth.ts db.ts utils/ trpc/ server.ts client.ts env.mjs package.json next.config.mjs tsconfig.json这个目录结构很克制,没有乱七八糟的样板代码,但它把一个项目运行需要的骨架全部立起来了。特别是env.mjs—— 它专门用来做环境变量校验,启动的时候如果缺少必填字段,直接抛错,不会带病运行。
2.3 数据库表设计与 Prisma Schema 的写法要义
初始化完成后第一件事,就是去prisma/schema.prisma里设计你的数据模型。我以我做的那个项目为例,当时有一个比较典型的需求:用户、文章、标签,三个阶段存在关联。看起来很简单,但实际写的时候有不少细节。
model User { id String @id @default(cuid()) email String @unique name String? createdAt DateTime @default(now()) posts Post[] } model Post { id String @id @default(cuid()) title String content String published Boolean @default(false) createdAt DateTime @default(now()) author User @relation(fields: [authorId], references: [id]) authorId String tags TagOnPost[] } model Tag { id String @id @default(cuid()) name String @unique posts TagOnPost[] } model TagOnPost { post Post @relation(fields: [postId], references: [id]) tag Tag @relation(fields: [tagId], references: [id]) postId String tagId String @@id([postId, tagId]) }这里有几个关键点,是实战中反复用到的:
- 主键用 cuid() 而不是自动递增数字。自动递增主键在分布式环境、多实例部署时会撞车,cuid 自带时间有序性,生成随机字符串,无需担心并发冲突,而且它在 URL 里不会暴露数据量。
- 多对多关系用显式的中间表(TagOnPost),而不是 Prisma 的隐式多对多。显式中间表的好处是以后想为"标签和文章的关系"加字段(比如排序、创建时间),直接改这个模型就行,隐式的就不方便。
- 发布状态用 Boolean,但配 @default(false)——这个默认值很有用,新创建的文章默认草稿,手动发布时才翻转为 true,防止手滑把没写完的东西直接暴露到线上。
这个 schema 写完后,跑两行命令同步到数据库并生成类型:
pnpm dlx prisma migrate dev --name init pnpm dlx prisma generate第一个命令会生成迁移 SQL 并执行,第二个命令重新生成 Prisma Client 的类型定义,这样你后续写 tRPC 路由时,所有表结构都已经被完整类型约束住了。
3. tRPC 路由设计:全链路类型安全的真正落点
tRPC 是 T3 技术栈里最容易被低估的一环。我用了一段时间后,意识到了它的本质:它把远程调用伪装成了本地函数,但代价是你必须按它的规矩组织路由。
3.1 路由拆分:不要把所有接口堆在一个文件里
tRPC 创建路由的方式比较灵活,但我不建议把冷热接口全塞进一个 router 文件。我一般按业务域拆,比如用户相关放user.ts,内容相关放post.ts,然后在根 router 里合并:
// src/server/api/root.ts import { createTRPCRouter } from "~/server/api/trpc"; import { userRouter } from "~/server/api/routers/user"; import { postRouter } from "~/server/api/routers/post"; export const appRouter = createTRPCRouter({ user: userRouter, post: postRouter, }); export type AppRouter = typeof appRouter;前端调用的时候,就是trpc.user.getProfile.query()这样,路径和函数名一一对应,可读性非常高。
但这里有一个很实际的坑:命名空间嵌套太多层容易让调用方脑袋疼,尤其是跨团队协作的时候。我的建议是保持两层,最多三层。超过三层就要想想是不是路由划分粒度太细了。
3.2 输入校验:Zod 与 tRPC 的组合拳
T3 项目中,tRPC 默认搭配 Zod 做输入校验。别嫌这一步麻烦,你在入参校验上省的时间,后面都会用在排查线上莫名其妙的错误上。
import { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "~/server/api/trpc"; export const postRouter = createTRPCRouter({ create: protectedProcedure .input( z.object({ title: z.string().min(1).max(100), content: z.string().min(1), tagIds: z.array(z.string()).optional(), }) ) .mutation(async ({ ctx, input }) => { // ctx.session.user.id 是当前登录用户 return ctx.db.post.create({ data: { title: input.title, content: input.content, authorId: ctx.session.user.id, tags: input.tagIds ? { create: input.tagIds.map((tagId) => ({ tagId })) } : undefined, }, }); }), });这里有三个细节值得注意:
protectedProcedure和publicProcedure的区别。前者要求用户必须登录,否则直接拒绝请求;后者允许匿名访问。这是我在 T3 项目里用的最顺手的权限控制手段——把权限下沉到路由层面,而不是在业务代码里到处写 if。- Zod 校验规则写在函数外面。这样这个 schema 也可以被其他地方复用,比如前端表单校验。一份 schema,前后端共用,永远不用改两处。
- mutation 和 query 的区分必须严格执行。只要是发起数据变更的操作(增删改),必须是 mutation,否则 Next.js 的缓存和 React Query 的缓存会做出错误的行为假设,导致页面状态变得诡异。我在这个坑上栽过一次,明明点击了"发布",页面死活不刷新。
3.3 前端调用:类型推导带来的舒爽与代价
前端调用方式,以 React 函数组件为例:
import { api } from "~/utils/api"; function PostCard({ postId }: { postId: string }) { // 这个 query 的返回值类型是完全自动推导出来的 // 你不需要写任何类型定义,后端加字段它就知道 const { data: post, isLoading } = api.post.getById.useQuery({ id: postId }); if (isLoading) return <div>Loading...</div>; if (!post) return <div>Not found</div>; return <div>{post.title}</div>; }使用体验就是:不需要像 REST 那样为每个接口额外写前端类型定义,也不用维护一个接口文档。后端改了入参结构,前端代码的编译就直接报错。这种"改错一处,全链路察觉"的特性,在 SaaS 项目迭代中真的是舒服。
但代价也很直接:
- tRPC 对弱网环境的容错没有 REST 那么直白。网络层的状态需要在 React Query 层面手动处理,比如重试策略、缓存时长。
- 调试工具虽然可选配,但自带的 DevTools 生态和 Postman 之类的成熟工具比,确实显得单薄。这事儿见仁见智。REST + Swagger 的优势是"接口可视、各方公认",而 tRPC 的类型来自代码,没有浏览器的"输入 URL"体验。
如果你做的是面向多个外部客户端(比如移动端、小程序、第三方开放平台)的平台型项目,我建议还是老老实实做 REST API 层。但如果产品形态是"同一拨人维护前后端、没有强外部接口义务",T3 这套组合在我经验里比传统搭档高效三成以上。
4. 认证与会话管理:T3 项目里最容易乱的一环
T3 默认集成了 NextAuth.js,也就是现在的 Auth.js。这个库的优点是从小项目到大项目都有成熟路径,但配置不当的话,踩坑能把你心态搞崩。
4.1 NextAuth 接入:Provider 的配法
拿最常见的 GitHub OAuth 举例。初始化 T3 时勾选 NextAuth,然后在环境变量里配:
GITHUB_CLIENT_ID=your_client_id GITHUB_CLIENT_SECRET=your_client_secret NEXTAUTH_SECRET=some_super_secret_string NEXTAUTH_URL=http://localhost:3000有一个很多新手都会踩的坑:NEXTAUTH_URL 在本地和线上不能共用同一个值。本地是http://localhost:3000,线上必须是你的正式域名。这个变量是用于 NextAuth 生成回调地址的,配错了轻则登录回调 404,重则整条登录链路直接白屏。
NEXTAUTH_SECRET也很容易被忽略。它用来给会话 cookie 签名,不能随便填个字符串就完事,要足够长、足够乱。我习惯用openssl rand -base64 32生成。
4.2 在 tRPC 里如何拿到当前登录用户
T3 初始化的时候,会在 tRPC 的上下文(context)里帮你把session放进去。这意味着你在后端路由里可以直接读取当前登录用户,看这一段:
// src/server/api/trpc.ts 中核心的 createContext export const createTRPCContext = async (opts: { headers: Headers }) => { const session = await getServerAuthSession(); return { db, session, ...opts, }; }; // 然后在 protectedProcedure 里 export const protectedProcedure = t.procedure.use(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { ...ctx, session: ctx.session, }, }); });这段代码的逻辑是:每个请求进来,先去拿会话;如果路由声明的是 protectedProcedure,就检查有没有会话,没有就直接抛 UNAUTHORIZED 错误。你不用在每个业务函数里重复写"如果没有用户就拒绝"。
4.3 刷新令牌过期问题
NextAuth 的 JWT 策略默认是 session 里存 JWT,JWT 过期时间默认 30 天,这个对大多数 web 应用够用了。但如果你在做长期登录的应用,就需要在pages/api/auth/[...nextauth].ts里配置:
callbacks: { jwt: async ({ token, user }) => { if (user) { token.uid = user.id; } return token; }, session: async ({ session, token }) => { if (token.uid) { session.user.id = token.uid as string; } return session; }, }这里把 user.id 存进 token 再塞回 session,是因为 NextAuth 的默认 session 对象里并不包含数据库用户的 id,只有 email 和 name 等 GitHub 返回的信息。没有这个回调配置,你会在 ctx.session.user.id 上碰到一个 undefined,排查起来很隐蔽。
5. 部署上线:从本地验证到云端环境的完整链路
代码写好了,数据库迁移好了,本地pnpm dev跑得欢。接下来就是上线——这一步有一个原则:能交给平台做的事,别自己从头搭服务器。
5.1 数据库部署:我建议用托管服务
自己做 T3 项目的数据库部署非常痛苦。你用 Prisma 做迁移,但数据的备份、容灾、扩容都得自己管。对于大多数中小型项目,我更推荐用托管数据库服务,比如 Vercel Postgres、Neon、Supabase 等。带连接池的托管 Postgres 可以省去很多麻烦。
我的经验是:本地开发用 Docker 起 Postgres,线上用托管服务,两边只需要改个环境变量DATABASE_URL就行。Prisma 对这两个连接串的格式基本一致,切换成本很低。
5.2 应用部署:Vercel 还是自建服务器
T3 项目天然适配 Vercel,因为它是 Next.js 的母公司做的平台,零配置部署。在 Vercel 面板里导入 GitHub 仓库,填上环境变量,点一次 Deploy,剩下交给它就行。
但 Vercel 也有一些注意点,我实际踩过的:
- Serverless 函数的冷启动时间。tRPC 的 API 路由跑在 Serverless 上,首次请求会比较慢。优化策略是尽量缩小打包体积,避免在服务端代码里加载不必要的第三方库。
- 连接池大小限制。Vercel Serverless 的并发模型导致它和数据库的连接是"短时大量"模式,如果用 Prisma 直连 Postgres,可能会把连接数打满。这里我强烈建议给线上
DATABASE_URL使用带连接池的 URL(Vercel Postgres 的池化端点、Neon 的池化模式都可以)。
如果你更倾向传统服务器,那用 Docker 部署也很顺手。T3 项目里有一个Dockerfile模板,但其实用standalone模式更省事。在next.config.mjs里配置:
const nextConfig = { output: "standalone", };然后next build会生成一个.next/standalone目录,里面只包含运行所需的文件。把它拷贝到服务器,再配合 PM2 或 systemd 启动 Node 进程,一个精简部署就成了。
5.3 日志与监控:别等用户反馈才知道系统挂了
部署完之后,我最想提醒的是:日志和监控不能靠人肉盯。
Vercel 自带日志查看器,能看实时日志,但生产环境更建议接入 Sentry。T3 的 Next.js 模板可以直接把 Sentry 配置进去,前端报错、后端 API 异常、性能趋势都能在一个面板里看到。别以为这不是核心功能就跳过——等一场恶劣的线上事故发生,你就知道可观测性意味着什么了。
6. 常见问题排查:那些让你抓狂的问题到底出在哪
跑一段时间之后,你大概率会遇到几个标志性的问题,这里直接列一个排查手册。
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 前端请求接口报 401 | session 没拿到 / protectedProcedure 依赖的中间件没生效 | 检查 NEXTAUTH_SECRET 是否配置、回调里有没有把 user 塞进 session |
| tRPC 类型不匹配 | 后端改了返回值但没重启 dev server | 重新跑pnpm dev;如果还不行,重启 TS server |
| 页面数据怎么点都不刷新 | 用了 mutation 但没用 React Query 的 invalidate | 在 mutation 成功后调用utils.api.post.getById.invalidate() |
| Prisma 连接超时 | 数据库连接数被占满 | 换池化连接 URL,或调小连接池数量 |
| 部署后环境变量不生效 | Vercel 面板里没填,或者填完没 Redeploy | 环境变量改动后必须重新部署,改动不会热生效 |
| 登录后回调地址错误 | 本地和线上 NEXTAUTH_URL 混用 | 分别配置,本地指向 localhost,线上指向正式域名 |
| undefined 的 session.user.id | 没配置 session 回调把 user.id 写进 token | 参考 4.3 节添加 callbacks |
其中,"数据怎么点都不刷新"是新手最常问的问题。这里解释一下原因:React Query 的 useQuery 有缓存,同样的查询键在缓存有效期内不会重新发起请求。mutation 做完后,如果不手动让缓存失效,界面当然纹丝不动。解决办法很简单,在 mutation 里拿到utils对象然后主动失效:
const utils = api.useUtils(); const publish = api.post.publish.useMutation({ onSuccess: () => { utils.post.invalidate(); }, });等着你做到这一步,你对 tRPC 的理解基本就已经超过"会用"的层次了。
7. 关于这套方案的一些个人体会
最后聊点偏感受层面的东西。
T3code 这套东西,不是那种"面试用的技术",它是真的用来干活的。我在项目里实践了半年多,最大感受有三个:
第一,重构成本显著下降。以前改一个字段,前后端代码要同步改,漏一处线上才炸。现在后端改完,前端编译直接报错,所有关联地方都在"编译前"暴露。虽然 TypeScript 编译偶尔慢点,但相比线上事故,这点等待太值了。
第二,对新人友好。项目里来了新同事,不用背接口文档、不用猜返回结构。照着类型定义摸一遍代码,很快就能上手改需求。T3 的全链路类型安全,在团队层面的意义比个人层面更大——类型就是最精准的接口契约。
第三,你做出来的方案要克制。依赖 T3 提供的这套基础约定并不等于放弃自我架构。路由拆分、权限边界、缓存策略、部署细节,这些都得自己把关。技术栈只是给了你一个良好起点,最终靠的还是对业务的洞察和工程化的纪律。
对了,如果你准备搭新项目,我的建议是直接用 create-t3-app 起步,先跑通一个最小闭环,再逐步加功能。T3 这套东西最好玩的地方不是它本身多复杂,而是你把它融入自己工作流之后,会发现全栈开发原来可以这么顺。