ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

t3code实践:T3 Stack全栈开发如何实现端到端类型安全

t3code实践:T3 Stack全栈开发如何实现端到端类型安全

如果你最近在看全栈开发方向,多半会撞见 t3code 这个项目名。它并不是某个新框架,也不是什么黑科技库,而是一整套围绕 T3 Stack 搭建的全栈代码实践:用 Next.js 做前端层,TypeScript 做类型底座,tRPC 做前后端的接口通道,再用 Tailwind CSS 做样式系统,让类型安全从数据库一路贯穿到浏览器里的每一行调用。我自己动手把这条链路完整跑通之后,最大的感受是:原来前后端联调可以这么安静。以前写完接口要手动维护一份文档,前端拿到的响应结构还经常和后端对不上,现在应用层的类型直接由编译器检查,改一个字段到处报错,反而成了让人安心的信号。

这个项目适合三类人看:一是想接触现代全栈写法的前端或后端开发者,二是被业务代码里的类型断层和接口同步问题烦透的人,三是想找一个“小而完整”的样板工程做二次改造的团队。t3code 的价值不在于功能多复杂,而在于它把全栈开发里最容易出乱子的衔接环节,用类型系统整个焊死了。接下来我就按自己从初始化到部署的实操顺序,把每一步的选型逻辑、落地方案和踩坑记录都拆开讲。

1. 项目定位与技术选型:T3 方案凭什么能扛住全栈开发

1.1 传统前后端分离的痛点:接口文档和类型断层

先说清楚 t3code 想解决的问题。传统的全栈开发,最常见的是后端写一个 REST 接口,前端在另一个项目里用 fetch 或者 axios 去请求。表面上看分工明确,但实际维护起来有几个绕不开的痛点:后端改了字段,接口文档没同步更新;前端拿到了新的数据结构,但 TypeScript 类型还停留在旧版本;等到运行时才发现某个字段是 undefined,然后开始一轮又一轮的联调、抓包、查日志。

这些问题本质上都指向同一个根源:类型信息在前后端之间是断裂的。后端的 TypeScript 类型写在 server 目录,前端的类型写在 client 目录,两者之间没有任何强制约束,全靠人为同步。t3code 选择的 T3 Stack 就是为了把这个断裂点补上。

所谓 T3,核心是一种态度:尽量让所有东西都在编译期被检查到,不要等到线上运行才暴露问题。它不追求最新的框架特性,也不搞花哨的架构,而是把几个已经被大量验证过的技术组合在一起,目标就是“类型安全”和“开发效率”两个词。

1.2 T3 四个成员的职责拆解:前端层、类型层、API 层、样式层

T3 Stack 通常包含四个核心成员,每个成员在 t3code 里都有明确的岗位。

Next.js 负责整个应用的前端框架部分。它的 App Router 提供了文件路由、服务端组件、布局系统这些能力。更重要的是,它让一个项目里可以同时存在服务端代码和客户端代码,所以 tRPC 的调用可以发生在服务端,也可以发生在浏览器端,这为后面的类型贯通提供了物理基础。

TypeScript 是全项目的语言底座。前后端所有代码都用它写,这样类型信息就不是额外生成的文档,而是源代码本身的一部分。t3code 里没有 js 文件,连配置文件也都是 ts 结尾的,目的就是让类型检查覆盖到每一个角落。

tRPC 是这套组合里最特别的一个。它不像 REST 那样需要定义 URL 和请求方法,也不像 GraphQL 那样需要写一套 schema,而是直接把你写的后端函数暴露成可调用的“远程函数”。前端 import 一个方法,像调用本地函数一样调用它,参数和返回值类型全部自动推导出来。这就是 t3code 能做到端到端类型安全的关键。

Tailwind CSS 则负责样式层。它用原子化 class 的方式写样式,省去了维护一堆 css 文件的成本。说到底,这四个技术选的都不是最复杂的方案,而是组合在一起摩擦最小的方案。

2. 初始化与工程结构:快速搭出 t3code 骨架

2.1 用 create-t3-app 一条命令生成项目

t3code 的初始化比大多数人想象中省事。官方提供了 create-t3-app 脚手架,一条命令就能把 Next.js、TypeScript、tRPC、Prisma、Tailwind 这些全部配好。

打开终端,找一个合适的目录,执行:

npm create t3-app@latest t3code

如果你想用 pnpm(我个人更推荐,后面会讲原因):

pnpm create t3-app@latest t3code

命令执行后会出现交互式选项,主要需要确认这几个:

? Which packages would you like to enable? [x] Next.js [x] TypeScript [x] Tailwind CSS [x] tRPC [x] Prisma [x] ESLint ? Initialize a new git repository? Yes

这里有几个实操要点。第一,Node.js 版本建议 18 以上,版本太老会在安装依赖时报错。第二,如果公司网络环境不太好,npm 安装很容易卡住,建议直接换 pnpm,T3 官方文档也默认推荐 pnpm。第三,如果你暂时不需要登录鉴权,可以不勾 NextAuth,等用到再加,减少初始复杂度。

初始化完成后,进入项目目录安装依赖:

cd t3code pnpm install

装完依赖后先跑起来看一眼:

pnpm dev

浏览器打开 localhost:3000,能看到默认页面就说明骨架已经通了。

2.2 目录结构里藏着哪些关键模块

create-t3-app 生成的结构是经过设计的,每个目录都有明确职责。我见过很多人不看结构就乱塞代码,最后项目变成一团麻,所以我建议先花十分钟把目录读一遍。

t3code/ ├── prisma/ │ ├── schema.prisma │ └── migrations/ ├── src/ │ ├── app/ │ │ ├── api/ │ │ │ └── trpc/ │ │ │ └── [trpc]/ │ │ │ └── route.ts │ │ ├── page.tsx │ │ └── layout.tsx │ ├── server/ │ │ ├── api/ │ │ │ ├── root.ts │ │ │ └── routers/ │ │ │ └── todo.ts │ │ └── db.ts │ ├── trpc/ │ │ ├── server.ts │ │ ├── client.ts │ │ └── react.tsx │ └── styles/ │ └── globals.css └── .env

重点看三块。第一块是src/server/api,这里放着所有后端逻辑,root.ts是路由聚合入口,routers/下面按业务模块放接口定义。第二块是src/trpc,这里封装了 tRPC 的客户端和服务端实例,页面里 import 的api就是从这来的。第三块是prisma/schema.prisma,这是数据库结构的唯一事实来源。

.env文件是配置中心,数据库连接串、鉴权密钥都在这。注意 t3code 生成的项目默认会把.env加到.gitignore里,这个不要改动,敏感的配置信息千万别提交到仓库。

3. 核心链路实现:从数据库表到页面渲染的类型闭环

3.1 用 Prisma 定义数据模型并完成迁移

t3code 里我选择用经典的 Todo 待办事项来跑通完整链路,不要觉得它简单,越是简单的东西越能看清类型是怎么一路贯穿的。

先打开prisma/schema.prisma,定义一个 Todo 模型:

generator client { provider = "prisma-client-js" } datasource db { provider = "sqlite" url = env("DATABASE_URL") } model Todo { id String @id @default(cuid()) title String completed Boolean @default(false) createdAt DateTime @default(now()) }

主键用String+cuid()而不是自增数字,是因为 t3code 这类项目将来很可能部署到 serverless 环境,在分布式场景下自增 ID 不仅需要额外的序列查询,还会暴露数据量,cuid 是更稳妥的选择。

定义好之后,执行迁移命令把模型同步到数据库:

npx prisma migrate dev --name init

这个命令会做两件事:生成一个迁移文件记录表结构的变更,同时自动触发类型生成。如果你运行完发现@prisma/client的类型没更新,就再手动跑一次:

npx prisma generate

很多人第一次跑完迁移后,代码里还是找不到db.todo的类型提示,绝大多数情况就是prisma generate没执行,或者执行完编辑器缓存没刷新,重启一下 TS 服务端就好。

3.2 在 tRPC 路由里实现查询与变更

数据模型有了,接下来要把操作暴露成接口。在src/server/api/routers/todo.ts里写一个todoRouter:

import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "@/server/api/trpc"; import { db } from "@/server/db"; export const todoRouter = createTRPCRouter({ getAll: publicProcedure.query(async () => { return await db.todo.findMany({ orderBy: { createdAt: "desc" }, }); }), create: publicProcedure .input(z.object({ title: z.string().min(1).max(100) })) .mutation(async ({ input }) => { return await db.todo.create({ data: { title: input.title }, }); }), toggle: publicProcedure .input(z.object({ id: z.string() })) .mutation(async ({ input }) => { const todo = await db.todo.findUnique({ where: { id: input.id }, }); if (!todo) throw new Error("Todo not found"); return await db.todo.update({ where: { id: input.id }, data: { completed: !todo.completed }, }); }), });

这里有两个细节值得展开。

第一,getAll用query,因为它只读;create和toggle用mutation,因为它们会改数据。这个区分主要是为了语义清晰,也能在运行时日志里更好地区分请求类型。

第二,create的入参用了 zod 做校验。这是 t3code 里我非常喜欢的一点:校验逻辑直接定义在路由函数里,不需要额外写中间件。如果调用方传了一个空字符串,tRPC 会在进入函数体之前就拒绝请求,然后把校验错误返回给前端。这套机制在 REST 里通常要自己写逻辑,在 t3code 里是脚手架自带的能力。

写完之后,把todoRouter挂到根路由上,并导出AppRouter类型作为全局类型中心:

import { createTRPCRouter } from "@/server/api/trpc"; import { todoRouter } from "@/server/api/routers/todo"; export const appRouter = createTRPCRouter({ todo: todoRouter, }); export type AppRouter = typeof appRouter;

前端所有 api 调用的类型提示,最终都是从这个AppRouter推导出来的。

3.3 页面消费接口:Server Component 与 Client 调用

路由定义好了,接下来看页面怎么消费。

在 t3code 的 App Router 结构里,你可以直接在服务端组件里调用 tRPC。比如src/app/page.tsx里这样写:

import { api } from "@/trpc/server"; export default async function HomePage() { const todos = await api.todo.getAll(); return ( <main className="mx-auto max-w-xl py-12"> <ul className="space-y-3"> {todos.map((todo) => ( <li key={todo.id} className="rounded border border-neutral-200 p-4" > {todo.title} </li> ))} </ul> </main> ); }

这段代码的底层逻辑是:服务端组件把请求发到同一个 Next.js 服务里的 tRPC 路由,拿到数据后直接渲染成 HTML。不需要单独发 HTTP 请求,也不需要在浏览器里处理 loading 状态,因为服务端会在渲染完成前把数据备齐。

如果你的交互发生在浏览器端,比如点击复选框切换状态,那就用客户端组件配合 react 版的 api:

"use client"; import { api } from "@/trpc/react"; function TodoList() { const { data: todos, refetch } = api.todo.getAll.useQuery(); const toggle = api.todo.toggle.useMutation({ onSuccess: () => refetch(), }); return ( <ul> {todos?.map((todo) => ( <li key={todo.id}> <input type="checkbox" checked={todo.completed} onChange={() => toggle.mutate({ id: todo.id })} /> <span className={todo.completed ? "line-through" : ""}> {todo.title} </span> </li> ))} </ul> ); }

注意这里的useQuery和useMutation返回的对象里,data、isLoading、error这些字段的类型都是自动推导的。也就是说,你在写代码的时候就能看到todo.title是不是存在、id是不是字符串,一旦模型改了,前后端所有引用它的地方都会报错。这种体验用一次就回不去了。

4. 高频问题排查:t3code 开发里最常踩的坑

4.1 依赖安装与 Prisma 类型生成问题

第一个高频坑出现在依赖安装阶段。如果你用 npm 安装,大概率会遇到类似ERESOLVE unable to resolve dependency tree的报错。这不是代码问题,而是 npm 对 peer dependency 的解析太严格。解决办法有两个:要么改用 pnpm,要么执行:

npm install --legacy-peer-deps

我个人的选择是 pnpm,它不仅速度快,而且对依赖的存储更节省空间,后续项目膨胀时优势很明显。

第二个高频坑是 Prisma 类型缺失。现象是代码里写db.todo没有类型提示,或者在引用@prisma/client时直接报找不到模块。排查顺序一般是这样的:

症状可能原因处理方式
找不到@prisma/client模块依赖没装全执行pnpm install
模型新字段没有类型提示schema 变更后没重新生成执行npx prisma generate
数据库表结构没更新只改了 schema 没跑迁移执行npx prisma migrate dev
dev 服务里类型一直报错编辑器缓存未刷新重启 TS Server 或重启 dev

另外提醒一句:每次修改schema.prisma后,如果发现PrismaClient运行时行为和类型不一致,先跑prisma generate,再跑migrate dev,顺序不要反。

4.2 环境变量与部署阶段常见报错

t3code 项目的环境变量问题,集中在对DATABASE_URL的依赖上。本地开发时如果用的是 SQLite,.env里大概是这样的:

DATABASE_URL="file:./db.sqlite"

但项目推到生产环境后,SQLite 通常撑不住并发场景,而且也不适合 serverless 平台,因为它依赖本地文件系统。我建议部署前把数据库切换到 PostgreSQL,用 Neon 或 Supabase 提供的云数据库都可以。

部署到 Vercel 时,常见报错有这么几个。第一,构建阶段报DATABASE_URL is not defined,这是因为环境变量没有在平台控制台里配置。解决方法不是把变量写死在仓库里,而是去项目设置里添加环境变量。第二,Prisma Client 在 serverless 平台上的连接数过多,通常是连接池配置没调,Neon 这类服务一般会给你一个连接池地址,用它替换主连接串即可。第三,迁移文件没有被应用到线上数据库,你需要在部署流程里加一步迁移命令,或者用 CI 在发布前执行prisma migrate deploy。

4.3 让开发与维护体验更顺手的三个习惯

项目跑通只是开始,长期维护才是重点。我在 t3code 的实操中总结出三个小习惯,能明显减少返工。

第一个习惯是所有输入参数都用 zod 校验。哪怕是一个简单的 ID,也要z.string()一下。好处是后端接口不会因为脏数据而崩溃,前端也能拿到统一的错误格式。

第二个习惯是只在一个文件里定义AppRouter类型,不要到处重建入口。有的项目为了临时方便,会在页面里直接写 someProcedure,绕过了路由聚合,结果类型推导乱掉,排查起来非常痛苦。

第三个习惯是给 mutation 的onSuccess里触发refetch,而不是手动修改本地缓存。虽然 React Query 也支持乐观更新,但从稳定性和可维护性看,先让服务端确认结果再刷新列表,逻辑更简单直观。等你的应用对交互速度有更高要求了,再去做乐观更新也不迟。

最后再分享一个我个人的实操心得。做 t3code 这类全栈项目时,千万别一上来就堆功能。我的做法是先把 Todo 这个最小闭环完整跑通:数据库有表、后端有路由、页面有渲染、交互有反馈,然后再去加用户登录、文件上传、多用户权限这些外围能力。这样每次扩展都建立在一个类型安全且可运行的基础上,调试时你能清楚地知道问题出在哪一层,而不会出现“改了前端结果后端崩了,但两个项目都报错”的困境。

顺着这个思路,你可以继续给 t3code 加 NextAuth 接入登录体系,用 Prisma 新增关联表做多用户的数据隔离,或者接一个对象存储服务做文件上传。每个方向都有成熟的库和方案,而 T3 这套架构真正的吸引力在于:当你沿着类型安全的路径往前走时,大部分低级错误会在编译期就被挡下来,你只需要把精力花在真正有挑战的业务逻辑上。

RELATED READING

延伸阅读

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