
t3code这个项目名第一次出现在我眼前时我几乎是下意识地眯了下眼睛。老玩家看到t3都会心头一动 —— T3 Stack那套由Next.js、tRPC、Tailwind CSS和TypeScript组成的全栈组合拳圈内戏称Typesafe Trinity。而code后缀则透露出一点野心这不仅仅是某个演示项目更像是一套可以反复复用、拿来即跑的代码底座。如果你和我一样受够了“前端写接口、后端写DTO、联调靠翻文档”的原始生活那么这篇文章就是给你准备的。我打算按我实际动手的顺序来聊聊t3code这套体系它到底解决了什么问题、四个核心成员各自扮演什么角色、我从零搭到上线的完整过程以及那些只有踩过坑才会懂的细节。这篇文章既适合第一次接触T3 Stack的新手也适合已经上手但仍被类型错误和部署问题折磨的朋友。1. t3code是什么一套T3 Stack代码底座背后的核心套路聊t3code之前我先把T3 Stack的家底翻一遍。T3并不是什么颠覆性框架而是一组“已经足够好用、组合起来战力翻倍”的工具链。它的缔造者Theo前Vercel员工给这个组合的定义非常朴素端到端类型安全、全栈TypeScript、不用为了集成付出额外心智负担。1.1 T3 Stack的四个核心成员与各自分工这四件套的搭配拆开看都眼熟但合在一起有化学反应。Next.jsApp Router模式下的全栈框架提供页面渲染、路由、API路由、服务端组件是整个系统的骨架。选择它不是因为“大家都在用”而是因为它能同时承担BFF层和前端渲染层减少一套服务的维护开销。tRPC这是T3的灵魂所在。它让你在前端直接“调用”后端的函数不需要手写REST路径、不需要手写GraphQL查询字符串、不需要在接口变更后满项目找DTO。类型从后端函数一路推导到前端调用点中间零手工同步。Tailwind CSS原子化CSS方案。用t3code搭后台管理界面时我几乎没有写过一行传统CSS文件所有样式都内联在className里。这种方式在隔离样式污染、保证视觉一致性上非常省心。TypeScript全程类型覆盖的前提。没有TStRPC的优势直接归零所以这一层不是可选项而是地基。提示T3 Stack还有一个常被忽略但绕不开的成员——Prisma。严格说它不在“T3三件套”命名里但官方脚手架create-t3-app默认集成Prisma作为ORM。t3code里自然也少不了它后面我会重点讲它怎么和tRPC配合。1.2 t3code到底解决了什么痛点我见过太多项目死在了“接口联调”这个环节。前端同学拿着后端给的Swagger文档对着字段名猜含义后端同学为了兼容旧版本不敢动响应结构中间夹一个Postman流传的接口集合版本早就乱成一锅粥。t3code这套思路把这个问题从根上抹掉了后端函数签名就是前端的调用协议函数改了类型前端编译直接红想不问后端自己修都不行。另一个痛点是“样板代码”。传统全栈项目里每加一个数据模型你得写数据库建表脚本、写服务端接口、写前端请求封装、写类型定义四遍工作做完还可能前后不一致。t3code里Prisma的model定义是唯一的事实来源tRPC router直接操作Prisma返回结果前端拿到的类型天然和数据库字段对应。实测下来加一个模型的效率至少翻一倍。2. 核心技术点拆解端到端类型安全是怎么一步步落地的类型安全是t3code的口号但它不是自动发生的。要让类型从数据库一路贯穿到浏览器需要在每一层用对姿势。下面我按数据流动的方向从底层往上层拆。2.1 Prisma模型定义一切类型的起点在t3code里一切类型的源头是schema.prisma。我习惯先在这里把业务对象想清楚因为后端的Zod校验、tRPC的输入输出、前端的表单类型全都从这里派生。model Post { id String id default(cuid()) title String content String published Boolean default(false) author User relation(fields: [authorId], references: [id]) authorId String createdAt DateTime default(now()) updatedAt DateTime updatedAt } model User { id String id default(cuid()) name String? email String unique posts Post[] }这里有几个实操要点。第一主键用cuid而不是自增id避免遍历id被爬虫抓取也方便分布式生成。第二updatedAt是Prisma自动维护的前端展示“最后编辑时间”时不需要手动赋值。第三所有字段名用驼峰数据库里会自动转成下划线这个映射对上层透明但你在写原生SQL时得反应得过来。2.2 tRPC Router后端函数的类型安全容器tRPC的Router相当于一个“类型安全的函数抽屉柜”。每个procedure都是一个可被前端“直接调用”的接口但你不需要为它编写接口文档因为type就是最好的文档。import { initTRPC } from trpc/server; import { z } from zod; import { prisma } from /server/db; const t initTRPC.create(); const router t.router; const publicProcedure t.procedure; export const appRouter router({ getPostById: publicProcedure .input(z.object({ id: z.string().cuid() })) .query(async ({ input }) { return prisma.post.findUnique({ where: { id: input.id }, include: { author: true }, }); }), createPost: publicProcedure .input( z.object({ title: z.string().min(1).max(100), content: z.string().min(1), }) ) .mutation(async ({ input, ctx }) { return prisma.post.create({ data: { ...input, authorId: ctx.session.user.id, }, }); }), }); export type AppRouter typeof appRouter;这段代码里有个新手容易忽略的点.input()里的Zod schema不是摆设它会在运行时校验参数同时从静态类型中推断出input的类型。也就是说input.title在前端调用时如果传成数字编译过不去如果传空字符串运行时报400。双层防护哪个都逃不掉。另一个关键点是ctx。tRPC的context是一个每次请求都会创建的对象你可以往里面塞数据库实例、session信息、日志器。上面的createPost里ctx.session.user.id直接取当前登录用户作为外键 —— 这个能力在传统REST风格里需要手动从Header里解析Token再查库麻烦且容易漏。2.3 Next.js Server Component与tRPC的协作边界t3code默认使用Next.js的App Router于是会出现一个经典问题服务端组件里怎么调tRPC我推荐的做法是在服务端组件里创建一个独立的serverTRPC调用器它不走HTTP直接在同一进程内调用Router函数。因为服务端组件本身就在Node环境没必要再经过网络回环。import { createTRPCProxyClient, httpBatchLink } from trpc/client; import { headers } from next/headers; export const serverClient createTRPCProxyClientAppRouter({ links: [ httpBatchLink({ url: /api/trpc, headers() { return { cookie: headers().get(cookie) ?? , }; }, }), ], });这里有一个我踩过的坑服务端组件里如果用httpBatchLink请求会打给自己的/api/trpc路由这时候你必须把浏览器的cookie透传进去否则服务端组件里的tRPC调用拿不到session。很多人第一次在服务端组件里用tRPC发现拿不到登录态八成就是这个原因。更省事的方案是直接用内部函数调用但设置成本高一些我在t3code里最终选择了HTTP透传求一个统一。2.4 Tailwind CSS的工程化策略Tailwind在t3code里的角色是“样式基础设施”但它并不只是让你写原子类。真正跑起来之后我发现最关键的是这套约定用layer组织业务样式Tailwind 3.x支持layer components把重复的按钮、表格、表单样式抽成组件类避免每个页面写一堆重复的className。深浅色切换用CSS变量t3code的UI我用的是浅色默认、暗色可选。Tailwind的dark:前缀只认class策略所以我把根元素的class动态切换dark所有颜色天然适配。内容安全列表如果用了CDN版的Tailwind必须配置content扫描路径。开发时一时省事没配上线后样式突然全部丢失排查半天发现是扫描范围没覆盖动态渲染的组件。3. 从零到一的实操记录我如何用t3code六十小时搭出一套带后台的内容系统光讲原理容易飘我把我最近一次用t3code做的一个“带后台的内容发布系统”完整实操过程搬出来。这个项目不算大但覆盖了认证、数据库、文件上传、列表分页、后台编辑等全栈所有高频场景。从初始化到线上前后大约六十个小时其中踩坑占了二十小时。3.1 初始化create-t3-app的三分钟魔法t3code的基座我直接用create-t3-app生成它像一个经验丰富的包工头把杂活全干完。npm create t3-applatest t3code生成过程中它会问你要装哪些能力我建议全选NextAuth、Prisma、Tailwind、tRPC。就算暂时用不上后面也省得补配置。装完之后目录结构是清晰的这里有几点值得特别留意src/server/api/下放tRPC相关包括root.ts汇总所有子路由、trpc.ts创建router实例、routers/业务路由。src/server/db.ts是Prisma Client的单例出口。src/app/api/trpc/[trpc]/route.ts是tRPC挂在Next.js上的HTTP入口你一般不用动它但要知道它存在。注意生成器默认用的是最新版Next.js 14的App Router。如果你在网上搜到大量/pages目录的旧教程里面用getServerSideProps的地方在t3code里全都要翻译成服务端组件或Route Handler别直接照抄。3.2 设计数据模型与首个migration我在动手前先把业务模型画在纸上用户、文章、标签、文章标签关联、评论。画完后转成Prisma schema然后执行迁移。npx prisma migrate dev --name init_schema这里有个决策值得说一下文章和标签用多对多关系时我刚开始嫌Prisma自动生成的关联表不好定制比如想加一个排序字段所以手动创建了PostTag这个显式关联模型带position字段。经验是如果只是简单关联让Prisma自动生成就好如果你觉得关联表未来还要挂额外属性那从第一天就显式建模型省得后面第二次迁移伤筋动骨。migrate dev执行后Prisma会在本地生成一份Migrate文件同时重新生成Client类型。这时候你去跑npm run dev所有model的类型都已经能用了。如果改了schema但忘记跑迁移编辑器里会立刻飘红提示类型不一致这就是Prisma类型实时同步的好处。3.3 用NextAuth做认证并注入tRPC上下文认证是后台系统的第一道门。t3code里NextAuth和tRPC的集成方式我后来反复用已经形成肌肉记忆了——核心就是把自己的Session类型合并进Prisma的User模型然后在tRPC的context里挂上session。// src/server/auth.ts import { NextAuthOptions, getServerSession } from next-auth; import CredentialsProvider from next-auth/providers/credentials; import { prisma } from /server/db; import { compare } from bcryptjs; export const authOptions: NextAuthOptions { providers: [ CredentialsProvider({ name: credentials, credentials: { email: { label: 邮箱, type: email }, password: { label: 密码, type: password }, }, async authorize(credentials) { if (!credentials?.email || !credentials?.password) return null; const user await prisma.user.findUnique({ where: { email: credentials.email }, }); if (!user?.passwordHash) return null; const ok await compare(credentials.password, user.passwordHash); if (!ok) return null; return { id: user.id, email: user.email, name: user.name }; }, }), ], session: { strategy: jwt }, callbacks: { jwt({ token, user }) { if (user) token.id user.id; return token; }, session({ session, token }) { if (session.user) session.user.id token.id as string; return session; }, }, };密码这里集中说一句永远别存明文。我用的bcryptjs是纯JS实现部署到Serverless环境不用折腾原生依赖实测性能也能接受。注册时用hash(password, 10)登录验证用compare。那10轮salt迭代是安全与性能的常见平衡点别为了图快改成4也别为了“绝对安全”搞到14登录响应时间会变得很感人。tRPC侧把session塞进context这样所有需要登录的procedure可以在中间件里做统一拦截。这里要强调一个技巧不要把getServerSession(authOptions)在同一个请求里调用多次。tRPC的每次调用new了一个context如果你的后端一次请求里有五个procedure每个都去调getServerSession那会白白做五次Session解析。我在t3code里用一个模块级的getServerSession缓存只在context创建时执行一次。3.4 文件上传砍掉云端依赖的本地落盘方案t3code项目里有图片上传需求。最开始想用云存储的SDK但考虑到部署环境复杂、密钥管理麻烦我果断选了本地落盘方案上传接口接收multipart文件写入/uploads目录然后通过Next.js的public路由对外提供静态访问。import { NextResponse } from next/server; import { writeFile, mkdir } from fs/promises; import path from path; export async function POST(req: Request) { const formData await req.formData(); const file formData.get(file) as File; if (!file) return NextResponse.json({ error: no file }, { status: 400 }); const bytes Array.from(new Uint8Array(await file.arrayBuffer())); const ext path.extname(file.name).toLowerCase(); const allowed [.jpg, .jpeg, .png, .webp, .gif]; if (!allowed.includes(ext)) { return NextResponse.json({ error: file type not allowed }, { status: 400 }); } const filename ${Date.now()}-${Math.random().toString(36).slice(2)}${ext}; const uploadDir path.join(process.cwd(), public/uploads); await mkdir(uploadDir, { recursive: true }); await writeFile(path.join(uploadDir, filename), Buffer.from(bytes)); return NextResponse.json({ url: /uploads/${filename} }); }这个方案有几个改进点我后来才加上文件尺寸上限没在这里限制实际上前面应该加一层if (file.size 5 * 1024 * 1024)拒绝超大图文件名加随机串防止撞名和路径穿越扩展名白名单必须写死只靠文件后缀判断不保险但作为一个内部后台够用。如果在生产环境我建议把这个接口挂到受保护的路由下别裸奔在公网。3.5 最终部署Vercel还是自托管t3code这套应用部署到Vercel是最省心的但有几个前提数据库得用外部PostgresVercel自带的Vercel Postgres也行Prisma迁移要在构建前执行文件上传路径在Vercel实例上不持久所以我的本地落盘方案在Vercel上会失效。如果坚持自托管我推荐Docker Compose一把梭FROM node:20-alpine AS base WORKDIR /app COPY package.json ./ RUN npm install COPY . . RUN npx prisma generate RUN npm run build CMD [npm, run, start]然后把数据库和应用放在同一个compose网络里环境变量用.env挂载。这里有个关键细节PrismaClient在构建时需要能连到数据库生成类型所以Docker构建阶段的DATABASE_URL一定要指向真实可连的实例否则构建直接失败。我最初在CI里图省事填了一个localhost地址结果构建时Prisma连不上整个流水线红了一片后来改成填远程数据库地址才通过。4. 常见问题与排查技巧实录t3code从能跑到跑顺绕不开的几个坎任何技术栈在真实项目里都会露出獠牙。t3code这套组合虽然理念先进但不代表没有坑。我把高频问题按“症状-原因-解法”的格式整理成速查表下面再对每个问题做展开说明。症状根本原因标准解法前端调用tRPC提示类型不匹配后端router新增/修改字段后前端没有重新编译确保前后端保持monorepo或同一项目修改后重启dev server服务端组件里tRPC拿不到sessioncontext创建时未透传cookie在serverClient的headers里传cookie或直接用内部调用Prisma报错模型找不到某字段修改schema后未执行migrate或generate跑npx prisma migrate dev或npx prisma generate生产环境页面样式丢失Tailwind content配置未覆盖实际组件路径检查tailwind.config.ts里的content数组NextAuth登录后session为空JWT callback里没写token.id按上文配置在jwt和session两个callback里同步id部署后上传文件不显示平台实例文件系统不持久换云存储或把上传目录挂载到持久卷初次加载tRPC请求过多客户端组件每个调用单独发HTTP请求使用httpBatchLink聚合请求4.1 类型漂移问题这个必须放在第一个说因为它是T3最引以为傲的功能也最容易在协作中出幺蛾子。现象是后端改了一个字段前端调用处仍然显示旧类型甚至明明改了类型编译却还是过。根源多半是tsc的增量缓存或者dev server没重启。我在t3code里坚持用monorepo前后端同一份tsconfig出现类型漂移的概率会大幅降低。另一个隐藏坑如果你把tRPC的router定义放在一个单独npm包里给多个前端使用那每次修改路由后必须重新构建再发布包否则前端拿到的还是旧类型。实测下来能减少这种成本就别拆包等真正有多客户端需求再拆。4.2 客户端组件和服务端组件的边界用Next.js App Router时use client的组件里可以直接用tRPC的client hooks但服务端组件不行。我刚上手时习惯把所有组件都标成client图省事结果整个页面变成了一个巨大的客户端渲染物体SEO和首屏性能都受影响。后面我严格划分列表页、登录页这类需要SEO和信息展示优先的页面用服务端组件数据通过serverClient获取只有交互密集的局部组件表单、弹窗、按钮行才标注use client。这个边界划分不仅影响性能也影响调试。客户端组件里的错误堆栈可读性差因为被框架的hydration逻辑包了一层服务端组件的错误则直白地打印在终端。所以遇到难查的错误先判断组件渲染发生时到底在哪一端别跑偏。4.3 NextAuth的Session失效与过期t3code里用了JWT session策略好处是无状态、不需要查库坏处是你改了用户角色权限后要等token过期才能生效。我在后台系统里实测管理员给某个用户改了角色那个用户还带着旧角色至少一小时JWT默认过期时间一小时期间他能继续访问受保护接口。这在一个内部管理系统里是实打实的安全隐患。稳妥方案是把session过期时间调短或者在关键角色变更时给用户发一个强制重新登录事件。但JWT的刷新机制在NextAuth里没有原生的完美方案权衡后我选择了缩短过期时间配合前端定时静默刷新体验上没有明显感知。4.4 批量请求的性能陷阱tRPC官方的httpBatchLink能把同时段的多个请求合并成一个HTTP请求这本来是个性能优化的好事。但我遇到过恶心情况某个页面上同时有10个tRPC query它们被batch成一个请求结果后端路由解析时只要其中任意一个procedure报错整个batch都返回400前端一下子出现10个红色报错。排查半天其实是其中一个query参数非法。后面的经验是调试期可以暂时换成httpLink不聚合逐个请求排查上线前再切回httpBatchLink减少请求数。另外batch请求的大小有默认上限如果你某个请求返回的payload特别大别老怀疑是tRPC坏了先看看是不是把batch撑爆了。我的几点亲身体会写到这里t3code已经不是一个抽象名词了。对我来说它更像一套已经被验证过的“全栈协作姿势”数据库里一个字段的改动能像涟漪一样精确地传到前端的每一个调用点中间没有任何信息衰减。我承认第一次接触T3 Stack时质疑过它的实用性——毕竟Next.js自带API路由为何还要套一层tRPC但真实项目里写了几百个procedure之后我的体感是放弃手写REST和自定义类型换来的是“定义一次到处安全”的踏实感。每次后端改了输入输出前端编译立刻就知道哪里要改这种即时反馈带来的效率提升是任何接口文档工具都给不了的。如果你正准备用这套组合我的建议很简单别贪图大而全先拿一个小业务模块完整跑一遍感受类型从数据库到浏览器的完整链路。等你在这种全链路类型安全中尝到了甜头就再也回不去了。至于那些部署、会话、边界问题看看上面的坑你至少能少熬三个夜。