
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.js18.17 或 20.x低于这个版本跑 Next.js 14 会有兼容性问题pnpm8.x / 9.x推荐用 pnpm磁盘占用小依赖安装快PostgreSQL14 或 15T3 默认数据库是 PostgresSQLite 只适合本地快速验证Docker可选本地起数据库最省事的方式这里有个非常常见的问题很多人图省事直接在本地装 PostgreSQL但自己不太熟悉数据库运维导致后面连接串配错。我的建议是如果只是开发调试直接用 Docker 起一个docker run --name t3-dev-db -e POSTGRES_USERt3user -e POSTGRES_PASSWORDt3pass -e POSTGRES_DBt3code -p 5432:5432 -d postgres:15这条命令起的容器暴露了 5432 端口数据库名、用户名、密码都是显式的后面写环境变量的时候直接填进去就行。等你不需要了docker stop t3-dev-db docker rm t3-dev-db一删即可环境干净得很。2.2 初始化命令的选择与说明初始化项目建议直接跑命令没必要从零手写配置文件pnpm create t3-applatest它不像一些脚手架那么粗暴会逐步问你几个问题关键是这俩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 divLoading.../div; if (!post) return divNot 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_IDyour_client_id GITHUB_CLIENT_SECRETyour_client_secret NEXTAUTH_SECRETsome_super_secret_string NEXTAUTH_URLhttp://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 里存 JWTJWT 过期时间默认 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使用带连接池的 URLVercel 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. 常见问题排查那些让你抓狂的问题到底出在哪跑一段时间之后你大概率会遇到几个标志性的问题这里直接列一个排查手册。现象可能原因解决方案前端请求接口报 401session 没拿到 / protectedProcedure 依赖的中间件没生效检查 NEXTAUTH_SECRET 是否配置、回调里有没有把 user 塞进 sessiontRPC 类型不匹配后端改了返回值但没重启 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 这套东西最好玩的地方不是它本身多复杂而是你把它融入自己工作流之后会发现全栈开发原来可以这么顺。