t3code实战:T3 Stack全栈模板与端到端类型安全开发

我第一次在仓库列表里看到t3code这个名字时,第一反应是:这八成是一个围绕 T3 Stack 搭建的全栈模板项目。T3 Stack 这几年在开发者圈子里已经不算冷门——Next.js、tRPC、Tailwind CSS、Prisma,外加可选的 Auth.js,主打的就是一套 TypeScript 全栈方案。而t3code这个名字,拆开看就是“T3 技术栈 + code”,听起来像是一个专门沉淀 T3 生态代码模板、工程化最佳实践和完整项目脚手架的地方。

这篇文章我想从一个实际做项目的人的角度,聊清楚这套以 t3code 为代表的 T3 全栈模板到底解决了什么问题、整体架构是怎么设计的、从零初始化到部署上线有哪些环节,以及我在实际搭建过程中踩过的坑和排查思路。如果你已经会用 React 和 TypeScript,但还没试过 tRPC,或者正在犹豫要不要用整套 T3 方案来起一个新项目,这篇应该能帮你省下不少时间。

1. t3code 真正要解决的问题:端到端类型安全不只是口号

1.1 为什么全栈开发总在“类型断层”上翻车

先说个很常见的场景。你负责一个 Web 应用,前端用 React,后端是 Express 或者 Spring Boot,两边语言不一样,前后端之间靠 REST API 通信。前端调/api/user/list,后端返回一堆 JSON,前端拿到数据后要再写一遍 TypeScript 接口来声明返回结构。

问题就出在这里:你写的 TypeScript interface 和后端实际返回的字段往往对不上。后端改了字段名或者删了某个字段,前端根本不知道,直到用户打开页面发现按钮不见了,或者控制台报undefined,你才后知后觉地去排查。这种“类型断层”在前后端分离的项目里太常见了,我把这个状态形容为“两边各拿一份手抄的通讯录,一边改了电话号码,另一边还留着旧号码,等打电话时才发现打不通。”

小型项目还能忍受,一旦业务逻辑复杂起来,接口几十个上百个,类型维护成本就失控了。于是有人引入 OpenAPI,写一套 schema 定义,然后做 codegen 自动生成客户端类型;也有人用 GraphQL,靠 schema 做前后端类型对齐。这些方案有效,但都带了额外成本——你要学一套新的 schema 语言,要维护 codegen 流程,还要处理一堆配置。

1.2 T3 Stack 的核心:tRPC 如何消灭胶水层

tRPC 的思路完全不一样。它不生成任何 schema 文件,不搞代码生成,也不引入新的传输层协议。它只是在服务端定义函数,在客户端直接调用这些函数,而 TypeScript 的类型系统会自动把服务端的入参和返回类型“传递”到客户端。你在前端写userRouter.getList.query(),IDE 里马上能提示这个函数需要什么参数、返回什么结构,全程没有任何手写的接口定义。

这个体验的底层逻辑,是 tRPC 利用 TypeScript 的typeof和泛型推导,把服务端 router 的类型信息暴露给客户端。服务端定义了procedure,客户端通过createTRPCProxyClient或者 React Hooks 拿到带类型的调用器。整个过程相当于“接口就是函数”,前后端类型天然一致,不存在对不上的问题。

我印象很深的是第一次在项目里给前端同事演示 tRPC 时的效果:我改了一个返回字段,保存文件,前端那边tsc立刻报错,告诉你哪里用了旧字段。那一刻你才能真正体会到“端到端类型安全”是什么意思,它不是一个抽象概念,而是每次构建都在起作用的安全网。

1.3 这套方案适合什么场景

技术选型最忌讳“什么火用什么”,所以我也要说说 t3code 这类模板的适用边界。

适合场景不适合场景
中小型全栈应用、内部管理系统需要对外开放的高并发公共 API
快速验证的 SaaS 产品 MVP前后端团队完全分离、需要独立发布的大型系统
以 TypeScript 为主的前端团队尝试全栈开发有大量非 TypeScript 客户端(如移动端/小程序)需要复用接口
需要快速迭代、减少接口文档维护成本的项目对请求性能极度敏感、需要精细控制 HTTP 缓存的场景

用大白话说:如果你的客户端只有 Web,而且前后端都是 TypeScript 阵营,tRPC 是非常高效的选择;如果你要做开放平台,给第三方开发者提供 API,那还是老老实实用 REST + OpenAPI 或者 GraphQL。t3code 这类模板本身就是为前一种场景设计的,不要指望拿它解决所有问题。

2. 整体设计与技术选型:t3code 该怎么搭才不“为了模板而模板”

2.1 技术栈里的四个核心成员,每个都不是白选的

Next.js 是 T3 方案的骨架。它在框架层面同时支持服务端组件和客户端组件,这意味着大部分页面渲染可以在服务端完成,减少客户端 JavaScript 体积。Next.js 的 App Router 也提供了良好的路由组织方式,配合 React Server Components,默认就能获得不错的首屏性能。你可以把 Next.js 理解成一辆车的底盘,其他成员都是在这个底盘上装配的部件。

tRPC 是通信层。它负责连接前端 UI 和数据层。前面说了,它的价值在于类型安全和零 codegen。实际用起来,tRPC 让“写接口”变成了“写函数”,很多以前需要在 REST Controller 里做的校验、序列化逻辑,都能直接用 TypeScript 表达,代码量少了一大截。

Prisma 是数据层。它是一个 ORM,但和传统 ORM 不一样,它通过 schema 文件定义数据模型,再生成类型安全的 Client。你在写查询时,字段名写错了,编译器会直接报错。Prisma 还内置了迁移工具,数据模型变更可以生成迁移 SQL,配合数据库版本管理非常方便。

Tailwind CSS 是样式层。原子 CSS 的方案,好处是样式写在 class 里,不用频繁切换文件和上下文,限制团队写混乱的全局样式也有一定帮助。有人觉得 Tailwind 的 class 看着乱,但实际项目里它的可维护性比我预想的好很多,关键是团队要统一约定。

这四个组件组合起来,核心逻辑链是这样的:UI 组件调用 tRPC hooks,tRPC procedure 里操作 Prisma Client 查询数据库,返回数据后类型一路穿透回前端。整条链路都被 TypeScript 覆盖,这就是 t3code 这类项目最核心的体验优化。

2.2 目录结构:把服务端逻辑和 UI 层彻底分开

脚手架初版生成后,目录结构已经有了一个不错的格局。我建议按下面的方式组织代码,这也是 t3 社区项目里比较常见的实践:

src/ ├── app/ │ ├── (routes)/ # 页面路由,按业务模块拆分 │ ├── api/ │ │ ├── auth/ │ │ └── trpc/ │ └── layout.tsx ├── components/ ├── server/ │ ├── api/ │ │ ├── routers/ # 每个业务域一个 router 文件 │ │ ├── trpc.ts # tRPC 初始化、上下文创建 │ │ └── rootRouter.ts # 汇总所有子 router │ └── db.ts # Prisma Client 实例 ├── trpc/ │ ├── server.ts # 服务端调用 tRPC 的封装 │ └── react.tsx # 客户端 Provider 和 Hooks 封装 ├── lib/ │ ├── auth.ts # 登录认证配置 │ └── utils.ts └── styles/

这里有意思的是server/和trpc/两个目录的分工。server/api/routers放的是服务端业务逻辑,这些文件不会被打包到客户端;trpc/react.tsx是提供给客户端组件的 hooks 封装,也是前后端类型的桥梁。trpc/server.ts则用于在 Server Component 里直接调用 tRPC procedure,不需要发 HTTP 请求,适合在服务端渲染时预取数据。

我一开始搭建类似项目时犯过把 router 文件放在客户端组件目录里的错误,结果导致服务端代码被打包到浏览器 bundle,既危险又臃肿。后来遵循这套结构,就再也没出过这类问题。

2.3 关键配置决策:严格模式、环境变量和 ESLint

TypeScript 的严格配置是 T3 方案的底线工程。t3code 这类模板默认会开启strict,这是基础,但真正拉高安全水位的是noUncheckedIndexedAccess。这个选项会把数组或对象的索引访问标记为可能为undefined,逼着你写防御性代码。比如items[0]?.name,而不是items[0].name。第一次开这个选项时项目里会多出一堆类型报错,但修完之后你几乎可以确信运行时不会出现“读了 undefined 的属性”这种低级问题。

环境变量也需要提前分好类。Next.js 默认有NEXT_PUBLIC_前缀的环境变量会被打包到浏览器,其他变量只在服务端可用。tPRC 的数据库连接字符串、JWT 密钥、OAuth 客户端密钥都属于服务端机密,绝不能加NEXT_PUBLIC_前缀。我在真实项目里见过有人把数据库地址写进NEXT_PUBLIC_DATABASE_URL,结果前端 bundle 里带上了数据库信息,这是能用浏览器开发者工具直接看到的,非常危险。

ESLint 和 Prettier 建议初始化时就选上。ESLint 负责代码规则检查,Prettier 负责格式统一。很多团队纠结要不要用这两样,我的建议是:放进去,然后用默认配置跑起来,等团队形成习惯后,再根据项目特性调整规则。相比后期重构时被几百个格式问题折磨,初期花十分钟配置是非常划算的。

3. 从零初始化 t3code 的完整实操流程

3.1 用 create-t3-app 起步,并在初始化时做出正确选择

搭建项目的第一步是执行 init 命令:

npm create t3-app@latest

交互式向导会问你项目名、要启用哪些模块。以 t3code 为例,建议勾选 Next.js、Tailwind CSS、Prisma、tRPC、Authentication。新版向导里如果出现 Drizzle 选项,先不用管,Prisma 生态更成熟,后续要迁移到 Drizzle 也不难。

关于数据库选择,向导会问你要用 SQLite 还是 PostgreSQL。这里我的建议是:本地开发、Demo、学习项目可以直接选 SQLite,零配置,一个文件搞定,方便随时重置;但如果你确定后续要部署到生产环境,建议直接选 PostgreSQL,避免后期换数据库时调整 schema 语法和数据迁移。两种选法在项目初期差别不大,因为 Prisma 的 schema 定义方式基本一致,切换成本主要集中在迁移和数据导入导出。

初始化完成后,用npm run dev启动项目,访问localhost:3000就会看到默认首页。此时打开项目根目录,能看到prisma/schema.prisma、src/server/、src/trpc/等关键文件,整个脚手架的骨架已经摆在面前了。

3.2 定义第一个数据模型,完成首次迁移

我拿一个实际业务来演示:假设 t3code 是一个“短链接生成器”,用户输入长网址,系统生成一个短码。数据模型很简单:

// prisma/schema.prisma generator client { provider = "prisma-client-js" } datasource db { provider = "sqlite" url = env("DATABASE_URL") } model Link { id String @id @default(cuid()) original String shortCode String @unique createdAt DateTime @default(now()) updatedAt DateTime @updatedAt userId String? user User? @relation(fields: [userId], references: [id]) } model User { id String @id @default(cuid()) name String? email String @unique emailVerified DateTime? links Link[] }

定义好模型后执行第一条迁移命令:

npx prisma migrate dev --name init

这个命令会做两件事:生成一个 SQL 迁移文件,并重新生成 Prisma Client。之后你可以在代码里用db.link.create()这类类型安全的方法操作数据库。这里要注意,prisma migrate dev面向的是开发环境,它会自动应用迁移并允许重置数据库;生产环境部署时不要直接跑 dev 命令,要用prisma migrate deploy,这个前面提到过,后面部署环节还会细说。

3.3 写第一个端到端类型安全的 tRPC 接口

模型建好了,接下来是写接口。短链接的核心接口有两个:创建短链、通过短码跳转。我把创建短链写成一个 tRPC mutation:

// src/server/api/routers/linkRouter.ts import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; export const linkRouter = createTRPCRouter({ createShortLink: protectedProcedure .input( z.object({ originalUrl: z.string().url(), }) ) .mutation(async ({ ctx, input }) => { const shortCode = Math.random().toString(36).slice(2, 8); const link = await ctx.db.link.create({ data: { original: input.originalUrl, shortCode, userId: ctx.session.user.id, }, }); return { shortCode: link.shortCode, shortUrl: `${ctx.req.headers.host}/s/${link.shortCode}`, }; }), });

然后在rootRouter.ts里挂到总 router 上:

// src/server/api/rootRouter.ts import { createTRPCRouter } from "~/server/api/trpc"; import { linkRouter } from "~/server/api/routers/linkRouter"; export const rootRouter = createTRPCRouter({ link: linkRouter, });

客户端调用可以这样:

// src/components/CreateLinkForm.tsx "use client"; import { api } from "~/trpc/react"; export function CreateLinkForm() { const utils = api.useUtils(); const createLink = api.link.createShortLink.useMutation({ onSuccess: () => { utils.link.getMyLinks.invalidate(); }, }); return ( <form onSubmit={(e) => { e.preventDefault(); const formData = new FormData(e.currentTarget); createLink.mutate({ originalUrl: String(formData.get("url")), }); }} > <input name="url" type="url" placeholder="输入长网址" required /> <button type="submit" disabled={createLink.isPending}> {createLink.isPending ? "生成中..." : "生成短链接"} </button> </form> ); }

这里最值得关注的是:前端useMutation拿到的输入类型、返回类型,全部是从服务端 router 自动推断的。你在写originalUrl时写错了字段名,编辑器立刻标红;服务端返回的类型你也能直接点出.shortCode、.shortUrl,不需要打开浏览器看网络响应再自己补 type 定义。这就是端到端类型安全的直观体现。

3.4 把登录体系接进 t3code:Auth.js 集成

绝大多数应用都需要用户体系,t3 模板里默认集成了 Auth.js,不管是邮箱登录、GitHub OAuth 还是 Google OAuth,初始化时选好 provider 类型,剩下的主要是填写环境变量。

以 GitHub OAuth 为例,你需要在 GitHub 开发者设置里创建 OAuth App,拿到 Client ID 和 Client Secret,然后填入环境变量:

AUTH_SECRET=你的密钥 AUTH_GITHUB_ID=你的ClientID AUTH_GITHUB_SECRET=你的ClientSecret

AUTH_SECRET可以通过npx auth secret命令生成,也可以手动生成一串随机字符串。生产环境部署时,这个密钥必须正确配置,否则用户会话校验会反复失败。

有了登录体系后,tRPC 的 procedure 可以分成两类:公开的和受保护的。默认的publicProcedure任何用户都能调用;protectedProcedure会先检查会话,未登录则抛错。上面那段短链接创建代码里我用的就是protectedProcedure,因为短链接需要关联用户,登录后才能生成。

我建议你做两件事:一是把需要用户身份的接口全部改为protectedProcedure,宁可初学阶段多一些类型报错,也不能让敏感接口裸奔;二是在 Next.js 路由层面加上中间件保护,对某些页面要求登录后才能访问。t3 模板里有一个src/middleware.ts示例,可以按需配置。

3.5 部署时的坑:迁移策略和 Serverless 连接数

项目写完了,部署上线是另一个容易出现问题的环节。t3code 这类模板最常见的部署目标是 Vercel,因为 Next.js 本身就是 Vercel 家的产品,部署体验非常顺滑。你需要做的是把环境变量全部配好,然后让 Vercel 构建时自动执行prisma generate,生成对应平台的 Prisma Client。

生产环境跑数据库迁移要格外小心。不要在构建阶段执行prisma migrate deploy以外的迁移命令,尤其不要在生产环境跑npx prisma migrate dev,因为dev命令会尝试重置数据库,而生产库的数据一旦被清空,后果是灾难性的。正确做法是在发布前手动执行一次:

npx prisma migrate deploy

在 Serverless 环境里,还要注意数据库连接数问题。Prisma Client 默认会创建连接池,但 Serverless 平台会同时拉起多个函数实例,每个实例都建立数据库连接,很容易把连接数打满。解决思路有两个:一是给数据库连接字符串加上连接池限制,PostgreSQL 可以配pgbouncer=true或使用外部连接池;二是控制并发,在 Vercel 的配置里限制单个函数的并发数。我在早期项目里没注意这些,上线后高峰期数据库服务直接报Too many connections,排查了很久才定位到是连接数爆了。

4. 实战中常见的报错与排查技巧实录

4.1 tRPC 类型报错:上下文类型不匹配

见过很多次这种类型的报错:

Type 'Context' is not assignable to type '{ db: PrismaClient; session: Session | null; }'

原因大多是你在自定义 procedure 时,上下文类型和实际传入的上下文不一致。tRPC 的上下文是在每次请求时动态创建的,比如需要把当前用户 session 塞进 context,以便在 procedure 里通过ctx.session.user.id拿到当前用户。如果你在某个地方手写了一个 Context 类型,而它和createTRPCContext返回的实际结构不一致,TypeScript 就会爆出这种错误。

解决办法是:不要手写 Context 类型,直接用initTRPC.context<CreateContext>()里的推断类型。trpc.ts文件里会有一段类似这样的代码:

const t = initTRPC.context<CreateContext>().create();

这里的CreateContext是函数createTRPCContext的返回类型推断,它会自动把db、session、req等字段带进去。改了createTRPCContext的返回结构,其他受保护 procedure 的类型会同步更新,你不需要手动维护第二份类型定义。这个设计是 TypeScript 的类型系统自洽性的典型体现。

4.2 Next.js 15 动态路由 params 变异步了

从 Next.js 15 开始,动态路由的params属性变成了 Promise 类型,这是一次破坏性变更。如果你在页面组件里这样写,会报错:

export default function Page({ params }: { params: { id: string } }) { const id = params.id; // 报错:params 是 Promise }

正确写法是先await:

export default async function Page({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; // 后续逻辑 }

这个改动看似小,但凡是涉及动态路由的页面都要检查。我升级项目时因为这个报错过一大片,后来用脚本全局搜了一遍params.id才全部改完。如果你用的是旧版模板,升级 Next.js 大版本时务必把这部分作为重点审查项。

4.3 Prisma 查询的 N+1 和字段冗余问题

Prisma 用起来方便,但也容易写出性能不佳的查询。典型的 N+1 是查询列表后,在循环里逐条查关联数据。比如查询所有用户时,你想带上每个用户的链接数,如果在 for 循环里逐个执行user.link.count(),数据量大一点,响应时间就会呈线性恶化。

正确的做法是用include或者select一次查出来:

const users = await db.user.findMany({ include: { _count: { select: { links: true }, }, }, });

这样只发一条 SQL,就能拿到每个用户的链接数量。另一个常见的坑是全字段查询——Prisma 默认返回模型所有字段,但很多时候你只需要其中三两个字段。用select裁剪字段,不仅能减少网络传输数据量,还能让数据库查询更高效。Prisma 官方文档里的 “Query Optimization” 章节值得反复看。

4.4 构建慢、产物大:审视客户端包体

t3code 这类模板默认用 App Router,服务端组件不会打进客户端 bundle,但还是可能因为某些写法把大包体带进去。最常见的是在客户端组件里引了很重的库,比如图表库、富文本编辑器,或者把整个工具库整体 import。

排查方法是启用 Next.js 的构建分析:

npx next build --analyze

这个命令需要安装@next/bundle-analyzer,然后看生成的报告里每个 chunk 的大小分布。通常罪魁祸首是某几个大型依赖没做 tree-shaking,或者明明可以在服务端执行的逻辑被“use client”拉到了客户端。

我做过一个很典型的优化:把一个只在服务端用的解析库从客户端组件 import 改成在服务端 procedure 里调用,客户端 JS 体积直接降了两百多 KB,首屏加载时间快了接近一半。这种问题在构建报告出来之前是很难察觉的,所以建议每个阶段性迭代都分析一次包体,而不是等到页面慢得不行了再回头找。

还有一个 NG 片段值得提醒:不要把api相关服务端代码直接 import 进客户端组件。因为 route handler、Prisma Client 这些代码如果被客户端引用,Next.js 会尝试把它们的依赖也打包进去,导致构建失败或者包体异常膨胀。出现这种问题时,记住前面第 2.2 节说的目录分离原则,服务端代码只放src/server/,客户端组件只 importsrc/trpc/react.tsx里的 hooks。

最后分享一点个人经验

用过一段时间 t3code 这类模板后,我的总体感受是:它帮你免去了全栈项目里最琐碎、最容易出错的基础设施搭建环节,把端到端类型安全的开发体验变得顺手可用。但模板本身不会替你写好业务代码,也不代表用了它就能写出优质项目。真正让项目变好的,是你理解每条数据链路如何流转、每个查询如何执行、和每次安全策略如何加固。

我在实际维护中养成了一个习惯:每写完一组功能,就回看一下自己写的 procedure 和页面组件,检查有没有把本不该暴露给客户端的字段返回出去、有没有在循环里写数据库查询、有没有忘记给受保护接口加校验。这些习惯不是在官方文档里学到的,都是在一次次线上问题和自查里磨出来的。希望这篇 t3code 的拆解和实操记录,能帮你少走一些我已经走过的弯路。