t3code 这个名字,是我在本地建项目时随手起的代号,结果做着做着发现这套技术组合值得好好记一笔。它对应的是一套以 T3 Stack 为核心的全栈 TypeScript 实践——简单说,就是用 Next.js 搭页面、用 tRPC 打通前后端类型安全、用 Prisma 管数据库、用 NextAuth 做登录,再加上 Tailwind CSS 负责样式。这套组合特别适合一个人或者小团队快速搞出一个小而完整的应用,从数据库建模到页面渲染全都吃同一个 TypeScript 类型系统,整个开发链条顺滑得不像传统前后端分离项目。如果你是做全栈开发、想摆脱手工维护 API 接口文档,或者想看看端到端类型安全到底能带来多少效率提升,这篇内容应该能给你一份可以直接照着做的参考。
1. 项目定位与整体思路拆解
1.1 t3code 是什么,为什么选 T3 这套组合
T3 Stack 的核心思路其实很简单:与其在各种框架之间做复杂集成,不如选一整套从一开始就为 TypeScript 深度优化的工具链。t3code 这个项目本质上就是这套技术栈的一次完整实操,覆盖了一个典型业务系统最常见的几个模块——用户注册登录、业务数据的管理、页面直出和客户端交互。
为什么这么组合?最关键的原因是“类型安全”三个字。传统前后端分离项目里,前端和后端各自维护一份接口定义,经常出现后端改了字段名、前端没同步,运行时才炸出问题。t3code 直接用 tRPC 把前端调用和后端路由拧在一起,你在服务端定义了查询,客户端立刻就能拿到带完整类型的 hooks,改一个字段名,编辑器马上把两端的报错都标出来。这个体验用过一次就很难回头。
另外,Next.js 的 App Router 让服务端组件和客户端组件可以混着用,部分数据在服务端取好直接渲染,部分交互逻辑在客户端跑。配合 Tailwind,样式上不用写一堆 CSS 文件,开发速度能明显提上来。T3 这套组合的目标人群也很明确:不是要做超大型企业级中台,而是做那种“一个人能扛住、又能快速上线”的产品、内部工具或个人项目。
1.2 功能边界与技术选型对比
t3code 的功能边界我一开始就框死了:用户登录后可以发表内容、查看列表、管理自己的记录,管理员可以看全站数据。没有做复杂的权限系统和消息队列,理由很简单——技术组合的价值在于跑通核心链路,而不是堆功能。项目里真正要解决的是三个问题:数据怎么建模、接口怎么暴露、页面怎么拿到数据并渲染。
为了说清楚为什么选这套,我直接把常见的三种后端方案拉了个对比:
| 对比维度 | tRPC(T3 路线) | REST API | GraphQL |
|---|---|---|---|
| 类型安全 | 端到端自动推导,天然强类型 | 需要手工定义 DTO,前端重新声明 | 需要生成 Schema 和客户端类型 |
| 开发效率 | 后端写路由,前端立刻用 hooks | 需要写 controller / service / 客户端请求层 | 需要维护 resolver 和 fragment |
| 适合场景 | 前后端同仓、内部使用的全栈应用 | 面向第三方开放、多端消费的 API | 复杂聚合查询、多端数据需求 |
| 初学成本 | 低,学习曲线平缓 | 低,但类型维护成本高 | 较高,概念多 |
从表格能看出来,tRPC 不是要取代 REST 和 GraphQL,它更贴近“全栈应用内部的私有的 API 层”。如果你的应用永远只有一个前端消费,并且前端后端都是 TypeScript,那 tRPC 几乎是效率最优解。t3code 选这条路,本质上就是想让业务代码以最快速度从数据库走到页面,而不被接口层细节拖住。
当然,我也得提醒一句:tRPC 的端到端类型安全依赖前后端共享代码,如果哪天你要对外提供公开接口,或者有第三方客户端接入,那还是得在 t3code 旁边单独开一个 REST 网关。这一点在启动项目前就要想清楚,不然架构后期改起来很痛。
2. 从零初始化:环境准备与项目骨架
2.1 create-t3-app 初始化与选项取舍
t3code 的初始化我用的官方脚手架 create-t3-app,这一步比手工拼接各个库要省太多事。运行命令:
npm create t3-app@latest交互式提示会问项目名和要安装哪些模块。我记得当时的选择是:App Router、Tailwind CSS、tRPC、Prisma、NextAuth,TypeScript 是默认就有的,没有关闭选项。如果你是做纯展示型页面,不涉及登录和数据库,完全可以只勾 Tailwind 和 tRPC;但既然叫 t3code,我建议模块都选上,先跑通完整链路再决定砍哪些。
这里有个取舍点:为什么我要用 App Router 而不是 Pages Router?因为 tRPC 在服务端组件里通过 server caller 直连后端逻辑,这个体验在 App Router 下最顺,数据可以直接在服务端取好再传给页面组件,不需要额外一层 client 请求再 setState。如果你是旧项目迁移,Pages Router 也不是不行,但新项目没必要走回头路。
初始化完成后建议立刻跑一遍:
npm run dev能打开首页并且没有报错,再开始写业务代码。这个步骤能排除大量“脚手架生成后就坏了”的干扰项,我见过太多人一上来就改代码,结果最后分不清是自己改坏的还是模板本身的问题。
2.2 目录结构与关键依赖解读
create-t3-app 生成的目录结构本身就是一个很好的分层示范。核心目录大概长这样:
| 路径 | 作用 |
|---|---|
src/app | Next.js 页面和路由,App Router 风格 |
src/server/api/routers | tRPC 的业务路由,一个模块一个文件 |
src/server/api/root.ts | 汇总所有 router 并导出 appRouter |
src/server/api/trpc.ts | tRPC 上下文、路由创建器、procedure 定义 |
src/server/db.ts | Prisma Client 实例 |
src/server/auth.ts | NextAuth 配置和服务端鉴权辅助函数 |
src/trpc/react.tsx | 客户端 tRPC hooks 封装 |
src/trpc/server.ts | 服务端调用 tRPC 的辅助封装 |
我第一次打开这个结构的时候,最大的感受是“约定优于配置”。你不需要纠结某个查询应该放哪,业务逻辑放 routers,跨模块复用的逻辑抽到公共函数,数据库操作全部走 Prisma Client,页面只负责调用。这种清晰的分层让项目变大以后也不容易乱。
关键依赖方面,除了 Next.js 和 React,真正影响业务写法的就四个:@trpc/server、@trpc/client、@trpc/next、@trpc/react-query;@prisma/client和prisma;next-auth和@auth/prisma-adapter;zod用于输入校验。这些包全部由脚手架统一装好版本,省去了相互兼容性的排查,这也是我推荐直接用脚手架而不是手动拼的原因。
3. 把类型安全做到极致:tRPC 与 Prisma 核心细节
3.1 tRPC 端到端类型安全的原理与配置要点
tRPC 的原理,一句话解释就是:把后端路由变成可以被 TypeScript 类型系统直接引用的对象。它不像 REST 那样把接口拆成 URL 字符串,而是让你这样定义一个查询:
import { createTRPCRouter, publicProcedure } from "@/server/api/trpc"; export const postRouter = createTRPCRouter({ list: publicProcedure .query(async ({ ctx }) => { return ctx.db.post.findMany({ orderBy: { createdAt: "desc" }, }); }), });然后在客户端组件里,你会得到一个和这个查询对应的 hook:
"use client"; import { api } from "@/trpc/react"; export function PostList() { const { data, isLoading } = api.post.list.useQuery(); if (isLoading) return <div>加载中...</div>; return ( <ul> {data?.map((post) => ( <li key={post.id}>{post.title}</li> ))} </ul> ); }注意,api.post.list.useQuery这个名字不是手写的字符串,它是从后端 router 对象自动推导出来的。后端返回的字段类型,前端这里直接就有提示。如果你在后端改了返回值,前端调用处的类型也会自动跟着变,编译期就能发现遗漏。这个体验怎么强调都不过分。
配置上有一点容易忽略:tRPC 的上下文(context)是连接数据库、session 等资源的桥梁。脚手架默认把 Prisma Client 和 session 塞进了上下文,所以在 procedure 里通过ctx.db和ctx.session就能拿到全局依赖。如果你自己加了一些资源,记得在trpc.ts的createTRPCContext里统一注入,不要在单个 router 里另起炉灶。
我自己用下来还有一个心得:输入校验一定要用 zod,别嫌麻烦。比如 create 接口:
import { z } from "zod"; export const postRouter = createTRPCRouter({ create: publicProcedure .input( z.object({ title: z.string().min(1, "标题不能为空"), content: z.string().min(10), }), ) .mutation(async ({ ctx, input }) => { return ctx.db.post.create({ data: { title: input.title, content: input.content, createdBy: ctx.session?.user.id ?? "anonymous", }, }); }), });zod 校验的好处不仅仅是防脏数据,更重要的是它作为类型推导源头,让 input 在业务代码里永远是精确类型,不需要你手动写接口类型。这直接把前后端的数据契约收敛到了同一段代码里。
3.2 Prisma 建模与迁移的实操细节
数据库建模这块,Prisma 的 schema 写起来很接近人类语言。t3code 里最核心的模型是用户和帖子,关系很简单:一个用户可以发多篇帖子。schema 里是这样写的:
model User { id String @id @default(cuid()) name String? email String @unique posts Post[] } model Post { id String @id @default(cuid()) title String content String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt createdBy String user User @relation(fields: [createdBy], references: [id]) }有几个细节我要特别提醒:
第一,主键我推荐用cuid()而不是自增 int。虽然 int 可读性好,但一旦将来做数据合并、分库或者在多端离线写入,字符串主键会省掉很多心。创建迁移的命令是npx prisma migrate dev --name init,它会生成 SQL 并同步更新客户端类型。
第二,字段命名要保持一致。Prisma 默认字段名就是数据库列名,所以建表时想好命名规范。t3code 里我统一用 camelCase,是考虑到 TypeScript 的读写习惯,但如果你更习惯数据库的 snake_case,也可以用@map做映射。这个没有绝对对错,怕的是混着用。
第三,每次改完 schema,记得执行npx prisma generate,或者直接跑npx prisma migrate dev,它通常会自动触发 generate。如果你发现 Prisma Client 类型没更新,prisma generate就是最常用的解药。
我还踩过一个坑:给已有字段改类型。比如把 Post 的createdBy从可选改成必填,如果表里已有空数据,迁移会直接失败。应对办法是先在数据库里把空数据处理掉,再切字段类型,最后再执行迁移。千万不要硬删表重来,除非你确认可以丢数据。
3.3 NextAuth 鉴权与 tRPC 的集成
鉴权我直接用了 NextAuth,配置也不算复杂。在auth.ts里配置 provider 后,Prisma adapter 会把用户、账号、会话等表自动管理起来,你不用手写 session 表结构。
tRPC 和鉴权的配合点是 procedure 的选择。脚手架默认给了两种:允许匿名访问的publicProcedure和必须登录的protectedProcedure。protectedProcedure的内部实现其实就是校验ctx.session,没有 session 就抛错。我实际编码时最常用的是受保护路由,比如用户只能改自己的帖子,在 mutation 里校验ctx.session.user.id === post.createdBy。
这里有一个真实容易踩的坑:在服务端组件里调用受保护的 tRPC 查询时,session 拿不到。原因在于 NextAuth 默认从 cookie 里恢复 session,而服务端组件需要先拿到请求的 headers。正确做法是像这样显式传 headers:
import { headers } from "next/headers"; import { createCaller } from "@/server/api/root"; import { createTRPCContext } from "@/server/api/trpc"; export async function getPostsForDisplay() { const ctx = await createTRPCContext({ headers: new Headers(await headers()), }); const caller = await createCaller(ctx); return caller.post.list(); }不加这段,你在服务端组件里拿到的 session 永远是 null,protected 查询必然报错。这个问题排查起来还不直观,因为没有语法错误,只有运行时的 Unauthorized 错误。我建议把这段调用封装成一个独立的 service 函数,这样页面组件只管拿数据,不用关心 tRPC 上下文怎么构造。
4. 完整业务闭环实操:从建表到页面渲染
4.1 建表与数据模型
前面 schema 已经定义了 Post 和 User,这是整个链条的第一环。实际操作顺序是:写 schema → 跑prisma migrate dev→ 确认生成的 Prisma Client 类型可用。
t3code 里我最开始只建了一张表,然后强制自己走完“建表 → 写 router → 页面调用”的闭环,再回来加第二张表。这样做的好处是,每一步出问题都能定位到具体环节,而不是等写完了所有代码才发现基础模型错了。
我还习惯给常用的查询加索引。比如列表页经常按createdAt排序,那我就在 schema 加:
@@index([createdAt])别小看这行,数据量到几十万条以后排序查询速度快很多。Prisma 的 schema 写索引非常直接,养成习惯就好。
4.2 编写业务 Router
数据模型就绪后,下一步是写 router。以帖子模块为例,我一般按这样的粒度拆分 procedure:列表查询、详情查询、创建、更新、删除。每个 procedure 尽量只做一件事,逻辑复杂就抽到函数里,不要让 mutation 里塞一大坨业务代码。
一个相对完整的例子:
import { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "@/server/api/trpc"; export const postRouter = createTRPCRouter({ list: publicProcedure .input(z.object({ limit: z.number().min(1).max(50).default(20) })) .query(async ({ ctx, input }) => { return ctx.db.post.findMany({ orderBy: { createdAt: "desc" }, take: input.limit, include: { user: { select: { name: true } } }, }); }), byId: publicProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const post = await ctx.db.post.findUnique({ where: { id: input.id } }); if (!post) throw new Error("帖子不存在"); return post; }), create: protectedProcedure .input(z.object({ title: z.string().min(1), content: z.string().optional() })) .mutation(async ({ ctx, input }) => { return ctx.db.post.create({ data: { title: input.title, content: input.content, createdBy: ctx.session.user.id, }, }); }), update: protectedProcedure .input(z.object({ id: z.string(), title: z.string().min(1), content: z.string().optional() })) .mutation(async ({ ctx, input }) => { const post = await ctx.db.post.findUnique({ where: { id: input.id } }); if (!post || post.createdBy !== ctx.session.user.id) { throw new Error("无权修改"); } return ctx.db.post.update({ where: { id: input.id }, data: { title: input.title, content: input.content }, }); }), delete: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { const post = await ctx.db.post.findUnique({ where: { id: input.id } }); if (!post || post.createdBy !== ctx.session.user.id) { throw new Error("无权删除"); } return ctx.db.post.delete({ where: { id: input.id } }); }), });写完 router 后,去root.ts把它挂到 appRouter 上:
import { createTRPCRouter } from "@/server/api/trpc"; import { postRouter } from "@/server/api/routers/post"; export const appRouter = createTRPCRouter({ post: postRouter, });只要这一步做了,客户端那侧就能立刻看到api.post.*系列 hooks,不用额外写任何请求代码。
4.3 服务端渲染与客户端调用
页面层我分了两种场景。列表页这种追求首屏速度的,我放在服务端取数,直接渲染:
import { getPostsForDisplay } from "@/server/services/post"; export default async function HomePage() { const posts = await getPostsForDisplay(); return ( <main> {posts.map((post) => ( <article key={post.id}> <h2>{post.title}</h2> <p>{post.content}</p> </article> ))} </main> ); }新建和编辑这种需要交互的,我用客户端组件加 tRPC hooks:
"use client"; import { useState } from "react"; import { api } from "@/trpc/react"; export function CreatePostForm() { const [title, setTitle] = useState(""); const [content, setContent] = useState(""); const utils = api.useUtils(); const createPost = api.post.create.useMutation({ onSuccess: () => { void utils.post.list.invalidate(); setTitle(""); setContent(""); }, }); return ( <form onSubmit={(e) => { e.preventDefault(); createPost.mutate({ title, content }); }} > <input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="标题" /> <textarea value={content} onChange={(e) => setContent(e.target.value)} placeholder="正文" /> <button type="submit" disabled={createPost.isLoading}> 发布 </button> </form> ); }这里最关键的代码是utils.post.list.invalidate()。它让创建成功后自动重新拉取列表数据,而不是手动拼请求去“刷新”。React Query 会在后端数据变化时重新执行查询,页面上立刻出现新帖子,体验接近实时。
为什么不在服务端组件里直接鼠标点击调用?因为交互逻辑天然属于客户端。服务端组件负责“首屏数据”,客户端组件负责“用户操作后的局部更新”。这种分工我把它们称为“能服务端直出就直出,需要交互就 hooks”,实际用起来非常顺手。
5. 常见问题排查与避坑实录
5.1 环境变量与鉴权相关的坑
t3code 从本地环境部署到服务器时,环境变量的问题会集中爆发。最常见的现象是生产环境登录失效或 Prisma 连不上数据库,十有八九是下面几项没配齐:
| 环境变量 | 作用 | 易错点 |
|---|---|---|
DATABASE_URL | 数据库连接串 | 生产库地址写成了 localhost |
AUTH_SECRET | NextAuth 加密 session 的密钥 | 本地一套、线上一套,导致登录态互相不认 |
AUTH_URL或NEXTAUTH_URL | 站点对外地址 | 忘了改,回调地址错误 |
我第一次部署时就是把AUTH_SECRET换成了随机值,结果用户登录后刷新就掉登录态,查了半天才发现。建议部署前用一个固定的强随机字符串,而不是每次重构都重新生成。
另外,Prisma 在生产环境跑迁移和本地不同,本地用prisma migrate dev,生产用prisma migrate deploy。如果少跑了这一步,页面会报table does not exist之类的错误。我习惯把prisma generate和prisma migrate deploy写进部署脚本,防止漏掉。
5.2 类型推断与缓存刷新问题
用 tRPC 最大爽点是类型安全,但新手最容易在类型上翻车。大部分报错都集中在两个地方:一是 input 没做 zod 校验,导致input是unknown类型,访问字段直接报错;二是 router 里返回的数据结构和前端期望不一致,导致data的字段访问失败。
解决办法是强迫自己“以 input 为类型源头”。所有传参都写 zod,不要依赖前端传什么就是什么。返回值不要偷懒返回整个 Prisma 对象,尽量用select裁剪字段,这样前端拿到的类型干净、不容易暴露多余数据。
缓存刷新方面的典型问题:创建完数据后列表不更新。原因是没调用invalidate。React Query 的缓存不会自己知道后端数据变了,你必须告诉它“这个 key 的数据已过期”。上面例子里的utils.post.list.invalidate()就是标准解法。如果你是更新单条记录,还可以用utils.post.byId.setData(id, newData)做渐进更新,页面不会闪跳。
5.3 部署与性能优化经验
部署环境我用的是通用 Node 服务器。构建之前先确认几件事:Node 版本要大于 Next.js 的要求,建议 18.17 以上;构建命令是next build,构建前确保数据库可访问;部署目录要包含node_modules或使用npm ci --omit=dev后完整上传,不然运行时会找不到 Prisma 的引擎文件。
性能优化上,最值得做的一件事是仔细区分服务端取数和客户端取数。t3code 首页的数据本来应该在服务端渲染,如果误用了useQuery,首屏会多一跳客户端请求,白屏时间明显变长。反过来,如果某个数据只在用户点击后才变,就没有必要在服务端组件里提前查询。这个取舍想明白了,T3 应用不会有大性能问题。
还有一个容易被忽略的点:客户端组件里不要直接导入服务端代码,特别是涉及数据库连接的模块。就算能编译,也会把 Prisma Client 打进浏览器包,有严重的安全隐患。t3code 中我通过src/server与src/trpc的目录约定来隔离两侧代码,客户端组件只从@/trpc/react引入 hooks,服务端逻辑绝不从客户端组件导入。
很多人问我在 t3code 里最深的体感是什么。我想说是“改需求的时候不心慌了”。以前前后端分离,加一个字段要同时改后端 DTO、前端类型、接口文档,任何一个漏掉都会在运行时暴露。现在加一个字段,从 Prisma schema 到 tRPC router 再到页面组件,全程有编辑器兜底,写错一个名字当场报错。这种确定感对一个需要长期维护的项目来说,比任何框架特性都值钱。最后一个建议:如果你也想跑一遍 T3 这套玩法,别贪多,先搭一个只有一张表的最小闭环,跑通再逐步加模块,比一次性铺开学的多,也扎实的多。