ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

T3全栈项目实战:从类型安全到部署上线的踩坑指南

T3全栈项目实战:从类型安全到部署上线的踩坑指南

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+ 或 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. 常见问题排查:那些让你抓狂的问题到底出在哪

跑一段时间之后,你大概率会遇到几个标志性的问题,这里直接列一个排查手册。

现象可能原因解决方案
前端请求接口报 401session 没拿到 / 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 这套东西最好玩的地方不是它本身多复杂,而是你把它融入自己工作流之后,会发现全栈开发原来可以这么顺。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进