T3 Stack全栈开发实践:用t3code搭建类型安全的Next.js应用
做技术这几年,"T3"这套东西时不时就会在朋友圈和技术群里刷到。t3code这个项目说白了,就是把 Next.js、TypeScript、tRPC、Prisma、Tailwind 这套"T3 Stack"的标准模板,再配上实际业务里经常需要的脚手架、路由、认证和部署配置,打包成一个开箱即用的全栈开发起点。
最早我是在几个 GitHub 仓库里看到这个词,很多人在找"t3code"相关的模板和最佳实践。它解决的核心痛点很直接:一个新的全栈项目,从零开始配置 TypeScript、数据库、API 层、UI 框架,光是把环境踩平就得花掉一两天时间。而 t3code 这类脚手架要做的,就是把这套流程压缩到几分钟,让开发者直接进入业务逻辑,而不是反复折腾工程配置。这篇文章不打算讲太虚的理论,就围绕我实际用 t3code 搭全栈项目的完整过程,把选型理由、目录结构、核心代码、部署细节和踩过的坑,全部摊开聊。
1. 为什么选择 t3code 而不是自己从零搭
1.1 全栈 TypeScript 的工程收益
先说个经常被忽略的事实:团队协作时最大的隐性成本,其实不是代码难写,而是数据在前后端之间的类型定义"失同步"。后端模型改了一个字段名,前端接口文档没更新,等联调的时候才暴露,这是我见过最多的返工场景之一。t3code 的核心逻辑,就是通过 tRPC 让前后端共享同一套类型推导。
很多新手容易理解错,以为 t3code 只是"把一堆工具放到一个模板里"。实际上它设计上有一个明确的价值主张:所有链路都跑在 TypeScript 类型系统之上。数据库 schema 由 Prisma 定义,生成的类型直接注入 tRPC Router,页面上调用 React Query 的时候,输入输出类型完全自动推导。换句话说,改一处,另一处立刻报错,从根源上堵住了"字段变了但没人知道"这个老大难问题。
1.2 对比传统 API 方案的取舍
在决定用 t3code 之前,我做过一个简单对比,直观感受一下它和传统 REST API 方案在开发效率上的差异。
| 维度 | 传统 REST + Swagger 方案 | t3code(tRPC)方案 |
|---|---|---|
| 类型安全 | 靠手写接口类型或生成工具,经常滞后 | 全自动类型推导,一处修改全链路报错 |
| 联调成本 | 需要前端手动查看文档,mock 数据 | 无需文档,IDE 提示即真相 |
| 开发效率 | 每个接口都要定义 route、controller、dto | 直接在 Router 里写方法,逻辑直观 |
| 适合场景 | 有复杂的对外开放 API 需求 | 前后端同团队维护的 Web 应用 |
当然我不是说 REST 一无是处。如果你要做开放平台、第三方集成,REST 依然是更通用的选择。但对大多数内部后台、工具型应用来说,tRPC 这套"直接写函数"的方式明显更加轻快。t3code 把这种模式固化成了标准流程,免去了每次都要重新搭建的成本。
2. 目录结构与核心技术栈拆解
2.1 初始化后的整体布局
我拿实际项目举例。用 t3code 创建完成一个项目后,看得最多的目录大致如下:
. ├── prisma │ ├── schema.prisma │ └── migrations ├── src │ ├── components │ ├── pages │ │ └── api │ │ └── trpc │ ├── server │ │ ├── api │ │ │ └── routers │ │ ├── auth.ts │ │ └── db.ts │ ├── styles │ └── utils └── ...这个结构最大的好处是"约定优于配置"。server 目录下的 db.ts 只负责创建 Prisma Client 单例,auth.ts 负责统一导出认证配置,api/routers 下每个文件对应一组业务路由。页面组件里通过trpc.xxx.yyy.useQuery()直接调用,完全不需要关心 HTTP 层是怎么序列化的。
2.2 tRPC 与 Prisma 的联动机制
在 t3code 的模板里,Prisma 和 tRPC 不是各管各的两套东西,而是通过server/api/trpc.ts这个文件"打配合"。你可以把这里的createTRPCContext理解成一种"依赖注入容器":每个请求进来,先把数据库连接和当前用户会话注入到 context 中,然后 Router 里的每个 procedure 都能直接使用这些资源。
// src/server/api/trpc.ts import { initTRPC, TRPCError } from "@trpc/server"; import superjson from "superjson"; import { ZodError } from "zod"; import { db } from "~/server/db"; export const createTRPCContext = async (opts: { headers: Headers }) => { return { db, ...opts, }; }; const t = initTRPC.context<typeof createTRPCContext>().create({ transformer: superjson, errorFormatter({ shape, error }) { return { ...shape, data: { ...shape.data, zodError: error.cause instanceof ZodError ? error.cause.flatten() : null, }, }; }, }); export const createCallerFactory = t.createCallerFactory; export const router = t.router; export const publicProcedure = t.procedure;这段代码看起来不长,但很多细节值得细说。superjson这个 transformer 是 t3code 的标配,它解决了普通 JSON 序列化无法处理Date、Map、BigInt等类型的问题。如果没有它,你从数据库读出一个Date字段,传到前端就直接变成字符串,需要手动 new Date() 恢复,很容易出时区偏差。用 superjson 后,类型信息在序列化时会被完整保留,前端拿到的直接就是正确的运行时类型。
2.3 Zod 与输入校验的整合
t3code 对输入校验这件事也做了标准化。以创建用户为例,典型写法是在 Router 方法里使用 Zod schema:
import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; export const userRouter = createTRPCRouter({ create: publicProcedure .input( z.object({ name: z.string().min(1), email: z.string().email(), }) ) .mutation(async ({ ctx, input }) => { return ctx.db.user.create({ data: input, }); }), });注意看这里,input定义连结了三个层面:
- 运行时校验:非法请求在进入业务逻辑前就被拦下
- 类型推导:
input参数在 mutation 回调中自动获得完整类型 - 前端推断:调用端直接通过
trpc.user.create.useMutation()获取错误反馈
这也是我觉得 t3code 在教学和实践上最值得模仿的地方:它不是简单地把多个框架堆在一起,而是让每一层都服务于"类型安全"这个目标。
3. 从零跑通一个 t3code 项目的实操记录
3.1 初始化命令与关键参数
我习惯用一个空目录来开始。官方创建命令比较直接:
npx create-t3-app@latest my-t3-app运行后会进入交互式选择界面,这里有几个关键选项值得提前想清楚:
- Next.js 版本:我建议直接选最新的稳定版,App Router 模式,一方面官方迭代最活跃,另一方面服务端组件和客户端组件的边界在模板里已经处理得比较规范。
- 数据库:开发环境快速起步选 SQLite,后面要上生产再切到 PostgreSQL。这个选择影响不大,因为 Prisma schema 本身是通用的,换数据库只需改 provider 和连接串。
- tRPC / Prisma / NextAuth:前期保证默认勾选,这样模板才能生成完整的 tRPC 上下文和认证基础代码。如果一开始不选,后面手动加会非常痛苦。
- Tailwind CSS:建议保留,哪怕你后续要用其他 UI 库。模板里很多示例组件的样式依赖它,省事。
创建完再启动本地开发:
cd my-t3-app npm run dev这时候打开http://localhost:3000,你看到的默认页面不只是"hello world",而是包含了一整套 tRPC 健康检查、数据库状态和认证入口的示例站点。我的建议是把这个初始界面当作"系统自检"来用:如果这几个模块都不是报错状态,说明环境基本通了。
3.2 Prisma 数据模型与迁移流程
t3code 默认生成的schema.prisma里已经有一个简单的 User 模型。实际项目里我会在此基础上扩展。比如加一个 Post 模型:
model Post { id Int @id @default(autoincrement()) title String content String published Boolean @default(false) author User @relation(fields: [authorId], references: [id]) authorId Int createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }然后执行迁移:
npx prisma migrate dev --name add_post_model这里要特别注意一个反直觉的点:prisma migrate dev会同时触发客户端类型生成。如果你在终端里看到"Generated Prisma Client"字样,说明node_modules/@prisma/client里的类型已经更新。这时回到代码里写 tRPC Router,立刻就能拿到新的db.post属性,完全不需要重启 Next.js 开发服务器。不过如果遇到类型没刷新,先手动npx prisma generate再重启 dev server,这招能解决大部分灵异报错。
3.3 写一个完整的业务 Router 示例
下面是我在项目初始化后第一个会写的完整业务逻辑,用来验证全链路是否顺畅。这个例子是"用户创建一篇文章并读取文章列表"。
// src/server/api/routers/post.ts import { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "~/server/api/trpc"; export const postRouter = createTRPCRouter({ list: publicProcedure.query(async ({ ctx }) => { const posts = await ctx.db.post.findMany({ orderBy: { createdAt: "desc" }, include: { author: { select: { name: true } } }, }); return posts; }), create: protectedProcedure .input( z.object({ title: z.string().min(3), content: z.string().min(10), }) ) .mutation(async ({ ctx, input }) => { if (!ctx.session?.user) { throw new Error("Not authenticated"); } return ctx.db.post.create({ data: { title: input.title, content: input.content, authorId: ctx.session.user.id, }, }); }), });注意我用了protectedProcedure而不是publicProcedure,这是模板里预置的一个"中间件":它会自动检查 session,未登录直接抛错。这也是 t3code 比很多手写模板强的地方:认证逻辑被抽象成了 procedure 级别,而不是在每个业务函数里重复写if (!ctx.session) throw...。
前端页面那边地调用也非常简单:
const utils = trpc.useUtils(); const createPost = trpc.post.create.useMutation({ onSuccess: () => utils.post.list.invalidate(), }); const { data: posts } = trpc.post.list.useQuery();这里值得反复体会的是invalidate()这个动作。React Query 在 t3code 里的默认行为是缓存后端数据。当你创建一篇文章后,列表不会自动更新,必须手动让旧缓存失效。在很多从 setState 思维迁移过来的人看来会觉得"多此一举",但实际用熟了会发现这是最省心的刷新策略:你不需要操心接口返回顺序、防抖、重复请求,失效后框架自己会重新拉取。
3.4 部署时需要调整的配置项
部署是 t3code 这类模板项目最容易翻车的环节。本地跑得好好的,一上服务器就报错,大多数时候逃不开这几个原因。
首先是环境变量缺失。生产环境必须有独立的.env.production或服务器环境变量,至少包含:
DATABASE_URL="postgresql://user:password@host:5432/dbname" NEXTAUTH_SECRET="随便生成一串足够长的随机字符" NEXTAUTH_URL="https://你的正式域名"然后是迁移策略。CI/CD 里我一般会在构建完成之后、启动服务之前执行:
npx prisma migrate deploy注意这里用的是deploy而不是dev。两者差别非常大:migrate dev可能会清空本地数据并触发种子脚本,适合开发环境;migrate deploy只应用迁移历史,不会动已有数据,适合生产。这个命令如果写错,轻则数据丢失,重则导致服务启动失败,是必须刻在脑子里的区别。
最后是 Next.js 构建时的功能说明。next build阶段,Next.js 默认会尝试静态渲染所有页面,页面里只要用了useSession或useQuery,就要确保在page.tsx里正确声明"use client",否则会报useSearchParams() should be wrapped in a suspense boundary之类的错。这个问题在 App Router 下尤其常见,建议先统一在布局文件里加:
export const dynamic = "force-dynamic";等后面真需要静态渲染某些页面时,再把这行删掉,逐个页面调整。
4. 常见问题排查与速查表
4.1 tRPC 调用报 404 或 500
如果你在页面里调用trpc.post.list.useQuery()返回 404,大概率是 API 路由没正确挂载。需要确认src/app/api/trpc/[trpc]/route.ts是否存在,并且文件里的 handler 是从~/server/api/root导出的appRouter。一个常见坑是文件路径大小写写错,在 Linux 服务器上很导致路由失联。500 错误则多半是 Zod 校验没通过,但没在前端拿到具体信息。我的排查方式是直接看服务端终端输出,或者临时在错误处理里打error.data。
4.2 Prisma 客户端类型没有更新
修改schema.prisma之后,如果 IDE 里还是找不到新增字段,先手动跑一遍:
npx prisma generate然后重启 TypeScript 语言服务。在 VS Code 里按Ctrl+Shift+P,执行"TypeScript: Restart TS Server"基本能解决。如果还不行,检查你是不是开了多个 TypeScript 版本,或者项目里有没有本地安装的tsc干扰。
4.3 NextAuth 在 App Router 下无法读取 session
t3code 的逻辑是推荐把 SessionProvider 放在RootLayout里,但服务端组件不能直接用客户端 context。模板里通常在src/app/_components/Providers.tsx里封装一个客户端 Provider:
"use client"; import { SessionProvider } from "next-auth/react"; import { TRPCReactProvider } from "~/trpc/react"; export function Providers({ children }: { children: React.ReactNode }) { return ( <SessionProvider> <TRPCReactProvider>{children}</TRPCReactProvider> </SessionProvider> ); }然后在布局里注册。这个方案稳定,但要注意TRPCReactProvider内部需要拿到当前请求头,所以在 App Router 下它必须被客户端组件包一层,不能直接在服务端组件中使用。
4.4 Tailwind 样式突然失效
这种情况大多是你引入了某个第三方 UI 库,而它生成的 CSS 放在自己的包里,没有经过 Tailwind 的content扫描路径。解决方式是在tailwind.config.ts的content数组里加上库文件路径:
content: [ "./src/**/*.{js,ts,jsx,tsx}", "./node_modules/第三库/dist/**/*.js", ],还有一个隐蔽坑:如果你打开了浏览器广告拦截插件,它可能拦截掉本地加载的字体文件,导致图标或特殊字符显示异常,这不是代码问题,别浪费时间排查。
5. 项目扩展思路与个人体会
t3code 这套模板用顺手之后,我最大的感觉是:它把一个团队的"默认技术规范"实体化了。之前每个新项目都要在技术选型和工程化配置上重新吵一遍,现在有了统一起点,讨论焦点被迫提前到业务建模和交互设计上。而且因为类型系统贯穿始终,新人在 IDE 里写代码时看到的提示几乎就是业务契约本身,学习成本和沟通成本都降了很多。
我个人的体会是,t3code 真正打动我的不是某个单点技术,而是它展示了"全栈工程化"应该长什么样:不是把所有流行库塞进 package.json,而是通过代码结构和流程约定,让类型安全、校验、认证、数据缓存这些横切关注点变成"默认遵守"的规范。
最后再分享一个小技巧。刚开始用 t3code 时,很多人会觉得trpc调试很麻烦,没有 REST 那样可以直接在浏览器地址栏输入 URL 测试。我的解决方法是写一个叫server.ts的独立脚本,通过createCallerFactory直接调用 Router:
import { createCaller } from "~/server/api/root"; import { createTRPCContext } from "~/server/api/trpc"; const ctx = await createTRPCContext({ headers: new Headers() }); const caller = createCaller(ctx); const posts = await caller.post.list(); console.log(posts);这样既不用启动 HTTP 服务,又能享受 tRPC 的类型检查和 Zod 校验,排错时比浏览器方便得多。后续如果业务膨胀,也可以把额外需要开放的接口迁移到 REST 或 GraphQL,反正有了稳定的核心 Router,外层适配交给框架就好。t3code 不是银弹,但作为全栈项目的起点,它确实称得上"不用动脑子的可靠方案"。