“t3code”这个项目名,源自我在做了多个前后端分离项目之后攒下的一整套全栈工程代码。它不是一个开源框架,也不是什么新语言,而是一套基于 T3 Stack 理念的代码实践:用 TypeScript 统一前后端、用 Next.js 承载应用、用 tRPC 打通 API、用 Prisma 管数据库、用 Tailwind 处理样式。这套东西解决的核心问题就是四个字:类型安全。
以前做前后端分离,后端接口文档一改,前端类型定义就过期;联调时参数名对不上,返回值嵌套层级不对,debug 到深夜。t3code 的做法是把 API 契约直接变成 TypeScript 类型,数据库表结构、接口入参出参、页面 Props 全部在同一套类型体系里流转。编译不过就说明代码有问题,改完类型就同步更新,基本上消灭了“接口文档和代码不一致”这类问题。
如果你是一个全栈开发者、独立开发者,或者是前端背景但想独立搞定完整产品的中小团队,这套方案很值得参考。接下来我按实际落地顺序,把 t3code 从技术选型、核心实现到部署排障的完整过程拆开讲。
1. t3code 的整体设计与技术选型解析
1.1 t3code 是什么:端到端类型安全的全栈代码架构
先解释一下“端到端类型安全”(End-to-End Type Safety)到底是什么意思。你可以把整个应用想象成一条水管系统:数据库是水源,页面是水龙头,API 是水管中间的水阀。传统项目里,水源和水龙头之间用的是不同规格的接口,数据库字段是一个类型,后端 DTO 又是一个类型,前端模型又是一个类型,中间每一层都要手动转换、手动对齐,一旦某一层没跟上修改,接口文档就变成了一纸空文。
t3code 要做的,就是让这条链路全程使用同一套 TypeScript 类型。数据库表结构由 Prisma Schema 定义,Prisma Client 自动生成对应类型;tRPC 的查询和变更操作直接复用这些类型作为入参与返回值;前端页面调用 tRPC 的 hook 时,TypeScript 编译器已经能推导出完整的数据结构。也就是说,类型系统在这里起到了一根“钢筋”的作用,把每一层浇筑在一起。改数据库字段,编译期就会报前端哪里没适配;改 API 返回结构,页面调用处立刻变红。
这套方案的完整技术栈是:Next.js(App Router)+ React + TypeScript + Prisma + tRPC + Tailwind CSS + NextAuth.js。可以粗略理解为一个“自带最佳实践的 Web 应用脚手架”,但它不是死板的模板,而是刻意把每一层的边界都收窄了:所有逻辑都往 TypeScript 上靠,垄断了数据流。“少而精”正是它效率高的根本原因。
1.2 每一项选型背后的取舍逻辑
很多人一看到 T3 Stack,第一反应是“是不是又冒出个全家桶”。其实每一层的选择都对应一类痛点:
Next.js 承担应用框架层。它要解决的是渲染问题。用纯客户端渲染(CSR)的单页应用,首屏慢、SEO 差,每次打开都在转圈;用传统服务端模板渲染,前端交互又很别扭。Next.js 同时给服务端渲染(SSR)、静态生成(SSG)和客户端渲染,按页面需求选,而且部署到 Vercel 只要一次推送。
Prisma 承担数据层。它把数据库表结构写成 Schema 文件,跑一条命令就能生成完整且类型安全的查询客户端。相比 TypeORM 和 Drizzle,Prisma 的迁移工具体验最顺,开发模式和线上模式都有对应的 CLI 命令,不太会出现“本地能跑线上挂了”的经典问题。
tRPC 承担 API 层。这是 t3code 里争议最大也是最出彩的一层。REST 写起来繁琐,参数要放到 query、path、body 三个位置;GraphQL 要维护 schema,还要处理 resolver 和前端代码生成。tRPC 简单到有点“反直觉”:你在后端定义一个函数,前端直接调用这个函数,类型和实现全程共享,连 codegen 都省略了。它特别适合“自己产品自己写”的场景,省掉一层胶水代码。
Tailwind CSS 承担样式层。原子类本质上是在消除样式文件的上下文负担。一个按钮需要什么样式,直接写在 className 里,不用来回切换文件。配合组件级封装,可以做到不改全局 CSS 也能统一视觉风格。
下面这张表是我当初选型时的对比梳理,可以帮你理解为什么最后落到这套组合:
| 解决的问题 | REST 方案 | GraphQL 方案 | t3code 用的方案 |
|---|---|---|---|
| API 类型一致性 | 手写类型,容易失配 | 需 codegen,工程重 | tRPC 原生类型推导 |
| 前后端联调成本 | 高,靠文档与工具 | 中,依赖工具链 | 低,编译期即检查 |
| 数据库迁移 | 独立管理,与代码分离 | 无关,需自行处理 | Prisma Module,迁移同步 |
| 渲染策略 | 需额外框架配置 | 需额外框架配置 | Next.js 原生支持 |
| 适合团队规模 | 大中型、多端 | 数据复杂、跨端访问 | 中小型全栈团队、独立开发者 |
t3code 不适合所有项目,但它非常适合“一个人或一个小团队把产品快速推向市场”的场景。因为每一层都在消灭手工作坊式的重复劳动,让你把精力花在业务逻辑而不是接口胶水代码上。
2. 核心细节解析与实操要点
2.1 项目初始化:create-t3-app 的交互式配置
从零搭建一个 t3code 工程,最省事的方式是用官方脚手架,它会帮你把上面的技术栈全部串好。执行命令:
npx create-t3-app@latest t3code执行后会进入交互式问答。我通常选择以下选项:
- TypeScript:必选,这是整条类型链路的地基。
- Tailwind CSS:选上,后面写样式省心。
- tRPC:选上,这是 t3code 的调度核心。
- Prisma:选上,数据层要由它接管。
- NextAuth.js:选上,认证是不可回避的需求。
- ESLint:选上,入门阶段应该让工具帮你兜底。
初始化完成后,你会得到一个清晰的目录结构。核心的几个目录如下:
src/ ├── pages/ # Next.js 页面 │ ├── api/ # API 接口挂载处 │ │ └── trpc/ # tRPC 唯一端点 │ └── trpc/ # tRPC 客户端封装 ├── server/ │ ├── api/ # 路由、中间件 │ │ ├── routers/ # 具体路由业务 │ │ └── root.ts # 路由汇总 │ ├── auth.ts # NextAuth 配置 │ └── db.ts # Prisma Client 实例 └── styles/ └── globals.css # Tailwind 指令注意src/server/api/routers这个目录,它比传统的 controllers 更轻,每个文件导出一个router对象,然后把业务函数写在里面。你不需要单独定义“数据库访问层”或“DTO 层”,因为 Prisma Client 自己就是访问层,tRPC 函数的返回类型就是 DTO。目录越少,维护成本越低。
初始化完成后,修改.env文件里的DATABASE_URL,把数据库指到你本地的 PostgreSQL 或 MySQL,然后运行数据库迁移,就能跑起来。首次启动会看到全栈应用空的“欢迎页”,此时类型链路已经通了,但业务代码还没写。
2.2 数据建模:Prisma Schema 精简实战
数据层设计直接决定后面 API 和页面写起来是否顺手。我用一个常见的“用户 + 文章评论”案例来说明。在prisma/schema.prisma里,核心模型可以这样定义:
model User { id String @id @default(cuid()) name String? email String @unique emailVerified DateTime? image String? posts Post[] comments Comment[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Post { id String @id @default(cuid()) title String content String? author User @relation(fields: [authorId], references: [id]) authorId String comments Comment[] published Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([authorId]) } model Comment { id String @id @default(cuid()) post Post @relation(fields: [postId], references: [id]) postId String author User @relation(fields: [authorId], references: [id]) authorId String content String createdAt DateTime @default(now()) @@index([postId]) }这里有几个细节值得展开。
关于ID 用 String 而不是 Int:不用自增数字,是因为自增主键在分布式环境、数据合并、导出导入场景下很容易撞车。cuid()由 Prisma 自动生成,不依赖数据库状态,测试环境的稳定性强很多。
关于@@index索引:这是新手最容易漏的。评论表查询时通常都带postId条件,如果不对postId` 建索引,全表扫描会随着数据量增长变成灾难。提前写索引,数据库查询计划就能直接用。这类索引在 Prisma 里只是两行声明,迁移时就会实际创建。
关于@updatedAt字段:它会在记录更新时自动写入时间,后端接口直接排序时有个可靠依据。
写完后,运行迁移:
npx prisma migrate dev --name init这个命令会做三件事:根据 Schema 生成 SQL 迁移文件、执行迁移、重新生成 Prisma Client。跑完后,你的类型系统已经从数据库层往上传导了。
2.3 tRPC 路由组织与 Zod 入参校验
t3code 的 API 层,本质上是把后端“函数”暴露给前端 “调用”。每个路由文件默认长这样:
import { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "@/server/api/trpc"; import { prisma } from "@/server/db"; export const postRouter = createTRPCRouter({ create: protectedProcedure .input( z.object({ title: z.string().min(1).max(100), content: z.string().optional(), }) ) .mutation(async ({ ctx, input }) => { return prisma.post.create({ data: { title: input.title, content: input.content, authorId: ctx.session.user.id, }, }); }), latest: publicProcedure.query(async () => { return prisma.post.findMany({ orderBy: { createdAt: "desc" }, take: 10, }); }), });这里有三个关键概念,值得拆开说。
Procedure 是路由的最小单位。每个 Procedure 要么是query(查询),要么是mutation(写操作)。它天然区分了 GET 和 POST/PUT/DELETE 的语义,但不会强迫你写一大段 HTTP 控制逻辑。
Zod 校验同时生成运行时检查和类型推导。input方法接收一个 Zod Schema,tRPC 会把它转成 TypeScript 类型,自动注入到input参数里。前端调用create时,编辑器自动提示{ title: string; content?: string };传错字段会立刻标红。这套就是 t3code 最吸引人的地方:入参校验不再是“后端自己写,前端猜”。
Context 贯穿请求上下文。ctx里注入了数据库实例和 session 会话。protectedProcedure会先检查用户是否登录,未登录直接抛出异常。前端调用未受保护的接口时,如果 session 过期,也会在类型层面保证拿不到用户 ID。
3. 实操过程与核心环节实现
3.1 认证与鉴权全流程落地
任何产品都离不了“谁是谁,能不能操作”这件事。t3code 在这块的默认方案是 NextAuth.js。初始化后,在src/server/auth.ts里配置:
import { getServerSession, type NextAuthOptions } from "next-auth"; import DiscordProvider from "next-auth/providers/discord"; import { PrismaAdapter } from "@next-auth/prisma-adapter"; import { prisma } from "@/server/db"; export const authOptions: NextAuthOptions = { adapter: PrismaAdapter(prisma), providers: [ DiscordProvider({ clientId: process.env.DISCORD_CLIENT_ID!, clientSecret: process.env.DISCORD_CLIENT_SECRET!, }), ], session: { strategy: "database" }, }; export const getServerAuthSession = () => getServerSession(authOptions);这里的关键是PrismaAdapter。它会把用户、账户、会话、验证码等数据自动写入数据库,不需要你自己写任何建表 SQL。登录后,用户信息自动同步到User表,session 存在Session表。后续通过 tRPC 的createContext注入 session:
// src/server/api/trpc.ts 关键片段 export const createTRPCContext = async (opts: CreateNextContextOptions) => { const session = await getServerAuthSession(); return { db: prisma, session }; };然后定义两套基础 Procedure:
export const publicProcedure = t.procedure; export const protectedProcedure = t.procedure.use(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { session: { ...ctx.session, user: ctx.session.user } }, }); });这样,凡是需要用户身份的业务逻辑,套上protectedProcedure就自动完成了登录校验。前端调用时,受保护接口传参会提示必须带上用户 token?其实不用,因为 NextAuth 已经通过 Cookie 携带了 session。前端不需要碰任何认证 token,类型安全在这里是一个“隐形保镖”。
实践中的一个小建议:不要把 OAuth 作为唯一登录方式。生产环境至少保留一种邮箱加密码的方案或验证码登录,否则第三方平台抽风时你连开发环境都进不去。我在一个项目里只接了 GitHub,结果某天 GitHub 回调超时,整组人卡在登录页半小时。
3.2 核心业务逻辑:CRUD 与服务端渲染
t3code 里最影响体验的问题是:什么时候直接访问 Prisma,什么时候通过 tRPC?
我的实践结论是:页面服务端渲染时,直接 Prisma;页面交互时,走 tRPC。因为服务端组件里调用 tRPC 需要额外初始化 server caller,反而绕了弯。而直接在页面里访问prisma.post.findMany,由于 Prisma Client 就在进程内,没有网络开销,渲染效率和代码清晰度都最佳。tRPC 的职责是处理浏览器发来的动态请求,例如创建、更新、删除。
一个实际的文章列表页,服务端代码可以这样写:
// app/posts/page.tsx import { prisma } from "@/server/db"; export default async function PostsPage() { const posts = await prisma.post.findMany({ where: { published: true }, orderBy: { createdAt: "desc" }, take: 20, }); return ( <main className="p-8"> <h1 className="text-2xl font-bold">最新文章</h1> <ul className="mt-4 space-y-4"> {posts.map((post) => ( <li key={post.id}> <a href={`/posts/${post.id}`} className="text-blue-600 hover:underline"> {post.title} </a> <p className="text-gray-500">{post.content?.slice(0, 80)}...</p> </li> ))} </ul> </main> ); }然后前端某个“点赞”交互,通过 tRPC 发送:
const utils = trpc.useUtils(); const likeMutation = trpc.post.like.useMutation({ onSuccess: () => { utils.post.latest.invalidate(); }, }); <button onClick={() => likeMutation.mutate({ postId: post.id })} disabled={likeMutation.isPending} > {post.likeCount} </button>这里的关键优化是invalidate 精确失效。更新成功后,你可以选择刷新整个页面的数据,也可以只让“最新文章”列表重新请求。t3code 推荐后者,避免大数据量页面整页重绘。
3.3 部署与上线:环境变量和迁移策略
部署 t3code 到生产环境,最省心的是 Vercel。不过有几个点必须提前处理,否则必然在生产环境翻车。
第一是环境变量,至少要配这几个:
DATABASE_URL=postgresql://user:pass@host:5432/mydb?sslmode=require DIRECT_URL=postgresql://user:pass@host:5432/mydb?sslmode=require NEXTAUTH_SECRET=你的随机密钥 NEXTAUTH_URL=https://你的域名这里特别说一下DIRECT_URL的来历。如果用的是 Neon、Supabase 这类 Serverless 数据库平台,连接本身要经过 Pooler,Vercel 的函数环境是短生命周期的,频繁连接会爆连接数。正确的姿势是让 Prisma Client 通过DATABASE_URL(连接池地址)执行常规 SQL,同时单独设一个DIRECT_URL给迁移工具使用。Prisma Schema 里加一行:
datasource db { provider = "postgresql" url = env("DATABASE_URL") directUrl = env("DIRECT_URL") }迁移文件在上线时需要用npx prisma migrate deploy执行,而不是dev,后者只在本地做检查,会要求交互式编辑。Vercel 构建命令里我通常会加上:
npx prisma generate && npx prisma migrate deploy && next build这样每次部署都会自动把 Schema 变更同步到数据库。如果某个库表结构已经在生产环境造了数据,migrate deploy会比手动改表安全得多。
4. 常见问题与排查技巧实录
4.1 tRPC 请求 500 或前端报错“No router match”
这是 t3code 项目里出现频率最高的一类问题。典型现象是:页面能打开,但某个数据区域一直空白或者报 500。
我先说最常见的根源:你在root.ts里忘记注册新的子路由。业务多了之后,人们经常新建一个routers/commentRouter.ts,但忘了在appRouter里挂载:
export const appRouter = createTRPCRouter({ post: postRouter, user: userRouter, // comment: commentRouter <- 忘了写这一行 });前端调trpc.comment.list.useQuery()就会匹配不到路由,typecheck 其实会立刻报错。但如果你用了any或者处理了错误边界,就能跑到运行时才炸。
另一个隐蔽问题:context 没注入session,protectedProcedure抛错后前端收不到正常错误提示。排查时先看浏览器 Network 面板里的响应体,如果是{"error":{"json":{"message":"UNAUTHORIZED"}}}之类,就基本确认是这个问题。
我的排查习惯是先在浏览器开发者工具里看网络请求的完整响应,再点开 Console 看TRPCClientError的 message。绕过前端的类型提示,直接在 server 代码里写console.log也是可行方案。t3code 的类型链路很长,但排查路径很好找:错误提示里会直接带你到具体的路由函数。
4.2 Prisma 迁移与数据库连接问题
Prisma 虽然好用,但它对数据库 URL 的格式和权限要求非常严格,我在本地踩过最大的坑是migrate dev能跑,但migrate deploy在生产环境报错。
常见报错与解决办法,可以先收藏:
| 报错 | 可能原因 | 解决办法 |
|---|---|---|
Environment variable not found: DATABASE_URL | .env 没加载或拼写错误 | 检查项目根目录 .env,确认变量名 |
Error: P1001: Can't reach database server | 数据库地址、端口、白名单 | 检查主机名是否含:5432,数据库服务是否公网可访问 |
Error: P1003: Database does not exist | 库里没创建同名数据库 | 先用客户端工具创建空库 |
Error: P3005: A database migration appears to have been applied | 迁移版本与本地不一致 | 对比_prisma_migrations表,必要时手动对齐 |
Error: P1012: error: Invalid schema | Schema 语法错误 | 运行npx prisma validate看具体报错 |
这里给一个很实用的提醒:不要在线上数据库直接跑手动 SQL。如果一定要手改,先备份然后用prisma migrate diff生成对应迁移文件。否则下一次自动迁移会和应用状态不一致,到时候修复比直接开发一个新的迁移更痛苦。
4.3 NextAuth 与 Cookie 的坑
认证模块的坑非常隐蔽,因为它通常只影响特定环境。一个典型问题是:
本地开发一切正常,推到生产环境后,登录成功但无法保持登录状态。
排查过程一般要看完这三件事:
NEXTAUTH_URL是否和当前域名一致。在 Vercel 上它会自动填,但自定义域名时必须手动填https://你的域名,不能带末尾斜杠。NEXTAUTH_SECRET是否设置了。生产环境如果不写,NextAuth 会直接报运行时错误,但在某些环境下它可能使用默认值,行为就非常诡异。- Session 策略是否选对。我用的是
database策略,会话记录存在数据库里,如果数据库迁移没跑成功,登录后立即失效。如果你改用jwt策略,则不需要数据库存会话,但跨域 Cookie 的限制会更多。
另外提醒一句:回调函数signIn里不要轻易 return false。我见过有人因为email域名过滤写错,导致所有合法用户都被挡在门外。调试认证问题的最好方式是在 NextAuth 的events里加日志,把 signIn/signOut 事件打出来,逐步排除。
4.4 Tailwind 样式失效与 hydration 警告
Tailwind 在大多数情况下“开箱即用”,但有两个高频问题:
一是content配置忘了加新文件路径。如果你把组件放到了src/components之外,或者目录名改了,Tailwind 扫描不到,样式类就不会生成。检查tailwind.config.ts里的content数组,把实际项目目录都加进去:
content: ["./src/**/*.{js,ts,jsx,tsx,mdx}"],二是hydration 警告。常见于服务端渲染和客户端渲染结果不一致,比如用了Date.now()、Math.random()、localStorage直接参与渲染。t3code 里经常在深色模式切换时踩到。解决思路很简单:把依赖浏览器状态的逻辑挪到useEffect里执行,或者在挂载后才渲染对应片段。
5. 性能优化与工程化进阶方向
5.1 用 invalidate 控制数据刷新时机
t3code 的查询缓存默认是认为是新鲜的,这带来了快速响应,但也容易让人忽略数据过期问题。我的原则是:写操作完成之后,立即让相关查询失效,而不是等待页面刷新。
一个典型做法是利用 trpc 的useUtils拿到工具实例:
const utils = trpc.useUtils(); const deletePost = trpc.post.delete.useMutation({ onSuccess: () => { utils.post.latest.invalidate(); utils.post.detail.invalidate(); }, });这在删除“最新文章”和“文章详情”两类页面数据时特别有效,避免同一个文章详情页重复请求。如果你业务上需要特别及时的反馈,还可以用setData直接写缓存,省掉一次网络请求,但 ui 即时更新带来的复杂性也要自己扛。建议先用 invalidate,等确认缓存逻辑稳定再考虑乐观更新。
5.2 Monorepo 复用:从单体应用到多包结构
t3code 到了一定规模后,可以自然演化为 monorepo。比如把server/db.ts、server/api拆到packages/db和packages/api,让多个应用(Web、后台管理、移动端 API)共享同一套数据库模型和路由。
实践中我的推荐是Turborepo + pnpm workspace:
apps/ ├── web/ # 前台 Next.js 应用 ├── admin/ # 管理后台 └── mobile-api/ # 移动端后端服务 packages/ ├── db/ # Prisma Schema 和 Client 封装 ├── api/ # tRPC 路由定义 └── shared/ # 公共类型与工具函数这样每个应用按需引入 tRPC 路由,数据库模型变化一次,全仓库编译期都能感知。代价是学习成本略高,所以只有在团队足够大或产品线足够多时才建议改造。
5.3 测试策略与 CI 基础
全栈类型安全虽然能挡住大量低级错误,但业务逻辑依然需要测试。t3-code项目里我通常搭三层测试:
- Unit 测试:针对工具函数,以及单个 tRPC 路由。
- Integration 测试:用真实数据库测试几个核心流程,如创建文章、更新用户资料。
- E2E 测试:至少覆盖登录到发布内容的完整链路。
在 CI 上,最经济的做法是在 push 时跑pnpm typecheck和pnpm lint,合并主干前跑pnpm test。类型检查往往比测试更早暴露问题,因为 t3code 的类型推导贯穿全栈,一次类型错误可能同时引发明文和隐式两处问题。
个人体会
最后说点实际操作中的感受。t3code 最让我觉得“回不去”的,是它的类型检查已经替我做掉了大量的“记忆工作”。以前我要记住接口的出参结构、数据库字段含义,再到处翻文档;现在只需要跟着 TypeScript 的红线走,数据长什么样、哪里可能为空、哪里需要处理,全都写在类型里。重点不是“写了一手漂亮类型”,而是让整个应用在写代码的那一刻就处于可理解、可安全变更的状态。
如果你准备尝试这套方案,我建议从一个小项目开始,哪怕是一个内部工具或简单的个人博客。先完整走一遍 Prisma 迁移、tRPC 路由、NextAuth 登录、Vercel 部署的流程,再逐步加大数据关系复杂度。当你第一次仅凭编译器就找出一处跨越数据库到前端的破坏性改动时,就会发现这整套工程代码的投入回报来得比你想象中要快。