1. t3code是个什么项目t3code 这一串字符第一次出现在我屏幕上的时候我正在纠结要不要把所有新项目都迁到 TypeScript 上。后来我干脆用 T3 Stack 搭了一个代号叫 t3code 的全栈样板工程本意是给自己留一个“开箱即用”的起点没想到在里面踩了一堆坑也摸清了不少路数。今天这篇就把这个项目的来龙去脉、技术选型、核心实现和翻车记录完整拆给你看。它不是一份官方文档的复述而是我从项目立项到部署上线期间真实跑过一遍的实操笔记适合那些想用一套类型安全的全栈方案快速搭建 Web 应用、又不想在前后端接口上反复对齐的人。t3code 能解决的问题其实很实在你写了一个 TypeScript 的数据模型前端请求后端接口时却还要手写类型定义、手写 API 文档一旦后端改了字段前端毫不知情运行时报错才发现。t3code 采用 T3 Stack也就是 Next.js TypeScript Tailwind CSS tRPC 的组合外加 Prisma 做数据库访问、NextAuth 做鉴权从数据库表结构到 API 输入输出再到 React 组件里的useQuery返回值全程由 TypeScript 类型推导贯穿。你改一个 Prisma 字段编译期就能看到哪里崩这种体验用过一次就回不去了。这套组合适合谁适合三类人第一类是独立开发者想一个人快速出全栈产品不想维护两套接口文档第二类是中小团队前后端都写 TypeScript希望减少联调中的低级错误第三类是刚接触全栈框架的进阶前端想弄明白“类型安全”到底是怎么在真实工程里落地的。当然如果你对 TypeScript 本身不熟读起来会稍吃力但我尽量把每个环节的“为什么”讲透你应该能跟上。1.1 为什么默认选 T3 Stack而不是别的组合先解释一下 T3 这个名字。它其实是取三个以 T 开头的核心技术的首字母TypeScript、Tailwind CSS、tRPC。再加上 Next.js 作为应用框架就组成了社区里常说的 T3 Stack。这个名字源自 create-t3-app 这个脚手架项目它的理念我到现在都觉得挺对先少引入那些复杂状态管理库只在确实需要的地方加复杂度。我在搭 t3code 之前也犹豫过要不要用纯 Next.js REST API或者上 GraphQL。REST 的问题是类型边界完全靠人肉维护你写在types里的接口定义跟后端实际返回的数据没有强约束关系改了一个字段很容易漏掉另一处。GraphQL 则有明显的学习成本和运行时开销小项目里引入 Apollo Server、Codegen 这些东西反而拖慢进度。tRPC 的思路完全不同它把服务端定义的函数路由直接暴露给客户端客户端调用api.todo.list.query()时返回值的类型自动跟着服务端走不需要你在客户端再定义任何接口类型。Tailwind 则是纯效率选择。t3code 里的页面我基本没写过一行自定义 CSS 文件全部用 utility class 组合配合clsx和tailwind-merge处理动态类名后面会专门展开讲。对我这种不太喜欢打开设计稿反复调像素的人来说Tailwind 是保证“能快速出界面”的最短路径。Next.js 则承担了路由和服务端渲染。t3code 里我用了 App Router服务端组件负责读数据渲染列表客户端组件负责表单交互两者通过 tRPC 和 React Query 连接。这个分工是 T3 Stack 的典型用法。1.2 类型安全链路到底安全在哪很多人第一次听说 tRPC 会问它跟直接导出函数有什么区别区别在于链路。拿一个极简的用户列表功能举例数据库表users由 Prisma 定义字段类型唯一来源就是schema.prisma服务端 tRPC router 里的listUsers过程返回Prisma.User[]这个类型在客户端自动可用客户端调用api.user.list.useQuery()拿到的一定是Prisma.User[]字段名写错、类型写错都会在编译器报错。这就像你用同一根水管从水塔一路接到水龙头任何一节的接口不匹配都会在接的时候发现而不是等打开水龙头才发现漏了。t3code 的核心价值就是把这根水管的每一节焊死。当然安全不是绝对的。tRPC 的保护是编译期的运行时你依然要自己校验输入。我在 schema 层用了 Zod 做输入校验tRPC 内置了对 Zod 的支持protectedProcedure也可以确保未登录用户连路由都碰不到。所以“类型安全”不是玄学它是工具链一层层叠加出来的确定性。1.3 项目的目录结构server 和 client 的边界怎么切t3code 的目录结构直接决定了你能不能快速定位问题。我沿用了 create-t3-app 生成的最佳实践并在此基础上做了一点调整。├── prisma │ └── schema.prisma ├── src │ ├── server │ │ ├── api │ │ │ ├── routers │ │ │ │ ├── todo.ts │ │ │ │ └── user.ts │ │ │ └── root.ts │ │ ├── auth.ts │ │ └── db.ts │ ├── trpc │ │ ├── server.ts │ │ └── react.tsx │ ├── app │ │ ├── api │ │ │ └── trpc │ │ │ └── [trpc] │ │ │ └── route.ts │ │ ├── layout.tsx │ │ ├── page.tsx │ │ └── _components │ │ ├── todo-form.tsx │ │ └── todo-list.tsx │ └── styles │ └── globals.css关键在于src/server下的所有代码都是只跑在服务端的src/app下的页面组件则是服务端组件和客户端组件的混合。服务端组件可以直接 import Prisma 查询数据库客户端组件则只能通过 tRPC 发请求。这个边界一开始很模糊我踩过的坑是在客户端组件里直接 import Prisma 会直接报错因为打包产物会带上数据库驱动甚至密码配置。所以 t3code 里的铁律很简单客户端组件只认api这个代理对象服务端组件可以碰 Prisma但也不要滥用。2. 初始化项目的正确姿势2.1 create-t3-app 脚手架每个选项怎么选初始化我推荐直接用官方脚手架因为它会把路由、鉴权、数据库客户端、tRPC 全套接好省去最无聊的胶水代码。命令很简单npx create-t3-applatest t3code它会问你几个交互选项我第一次跑的时候有些选项压根不知道后果这里直接给结论App Router 还是 Pages Router选 App Router。虽然 Pages Router 的getServerSideProps很直观但 App Router 是 Next.js 后续方向而且服务端组件配合 tRPC 的体验更扁平。是否需要 tRPC选上。这是整个项目的灵魂不要跳过。是否需要 Prisma选上。类型安全的源头就是数据库层没有 Prisma 的话 tRPC 的返回类型会失去约束。是否需要 NextAuth如果你要登录体系就选上。我建议选上因为 t3code 里我用它做会话和 protectedProcedure 鉴权。是否需要 Tailwind选上。是否需要 ESLint / Prettier默认选上不要省。Drizzle 还是 Prisma脚手架现在会让你在两者之间二选一t3code 用 Prisma理由只有一个生态更老牌、文档更全、团队上手成本低。Drizzle 我也试过SQL 风格很爽但遇到复杂关系查询时排查资料没有 Prisma 多作为新手样板项目Prisma 更稳。选完以后脚手架会帮你初始化 Git并且生成一套可以直接运行的代码。此时你应该先跑一遍npm run dev确认基础页面能打开再开始改属于自己的业务逻辑。2.2 环境变量与数据库选型脚手架生成的.env里最重要的就是DATABASE_URL。本地开发我用了 SQLite因为零配置文件即数据库适合跑通逻辑到了部署阶段再切 PostgreSQL。这一开始看起来像是偷懒实际上是个很聪明的策略——Prisma 的 schema 并不绑定某个具体数据库切换只需要改provider和连接串代价很小但开发期的效率提升立竿见影。如果你也用 SQLite.env可以长这样DATABASE_URLfile:./db.sqlite NEXTAUTH_SECRET随便生成一段足够长的随机字符串 NEXTAUTH_URLhttp://localhost:3000注意NEXTAUTH_SECRET不是拿来开玩笑的生产环境一定要换掉否则会话可以被人伪造。我自己生成随机串用的是openssl rand -base64 32比手敲靠谱得多。2.3 路由层级与脚手架自动生成的 tRPC 骨架初始化完成后脚手架会在src/trpc/server.ts和src/trpc/react.tsx里创建 tRPC 的编排代码还会在src/server/api/root.ts里注册一个appRouter在src/server/api/routers/post.ts里放一个示例路由。我建议先别着急删示例代码花十分钟把它的结构读一遍因为 t3code 里所有新路由都要按照同样的套路注册否则会出现客户端拿到 404 或者类型丢失的诡异问题。核心套路是这样的先在src/server/api/routers/下新建一个业务路由文件然后去root.ts里把它挂载到appRouter上代码大致长这样import { createTRPCRouter, publicProcedure } from ~/server/api/trpc; export const todoRouter createTRPCRouter({ hello: publicProcedure.query(() ({ msg: hello from todo })), });然后在root.ts里import { todoRouter } from ./routers/todo; export const appRouter createTRPCRouter({ todo: todoRouter, });记住不注册的路由在前端是调不到的。我最初漏了这一步结果前端一直提示类型错误排查半天发现是忘了挂载这种低级错误最容易在赶工时出现。3. 从零实现一个带登录态的 Todo 模块理论讲太多不如直接上手。t3code 的核心演示功能我用了一个 Todo 模块用户登录后可以创建自己的待办事项、查看列表、标记完成、删除。这个模块虽然小但覆盖了 Prisma 数据建模、tRPC 受保护路由、React Query 数据获取、乐观更新和表单校验几乎把 T3 Stack 的高频操作都练习了一遍。3.1 第一步设计 Prisma 数据模型打开prisma/schema.prisma先定义好用户和待办事项之间的关系。每个 Todo 都属于一个 UserUser 可以拥有多个 Todo。model User { id String id default(cuid()) name String? email String? unique emailVerified DateTime? image String? todos Todo[] accounts Account[] sessions Session[] } model Todo { id String id default(cuid()) text String done Boolean default(false) createdAt DateTime default(now()) updatedAt DateTime updatedAt userId String user User relation(fields: [userId], references: [id]) }这里有两个细节值得注意。第一个是id用cuid()而不是自增整数因为我后面要部署到无服务器环境分布式生成 ID 不会撞车也避免暴露业务量。第二个是userId和user的关系字段Prisma 会要求你在查询时用include: { user: true }或者用where: { userId: ... }过滤后者在列表场景性能更好。写完模型后执行npx prisma migrate dev --name init这一步会生成 SQL 迁移文件并且同时运行prisma generate更新类型。如果改了 schema 却忘了跑 generate客户端类型不会更新你会看到一堆诡异的类型错误。这个坑我至少踩过三次。3.2 第二步定义 tRPC 路由与鉴权Todo 的所有操作都应该要求用户登录所以这里用protectedProcedure而不是publicProcedure。protectedProcedure是脚手架里已经封装好的中间件它会从会话中取出用户信息未登录直接抛错。import { z } from zod; import { createTRPCRouter, protectedProcedure } from ~/server/api/trpc; export const todoRouter createTRPCRouter({ list: protectedProcedure.query(async ({ ctx }) { return ctx.db.todo.findMany({ where: { userId: ctx.session.user.id }, orderBy: { createdAt: desc }, }); }), create: protectedProcedure .input(z.object({ text: z.string().min(1).max(200) })) .mutation(async ({ ctx, input }) { return ctx.db.todo.create({ data: { text: input.text, userId: ctx.session.user.id, }, }); }), toggle: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) { const todo await ctx.db.todo.findUnique({ where: { id: input.id }, }); if (!todo || todo.userId ! ctx.session.user.id) { throw new Error(没有权限操作这条待办); } return ctx.db.todo.update({ where: { id: input.id }, data: { done: !todo.done }, }); }), delete: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) { const todo await ctx.db.todo.findUnique({ where: { id: input.id }, }); if (!todo || todo.userId ! ctx.session.user.id) { throw new Error(没有权限删除这条待办); } return ctx.db.todo.delete({ where: { id: input.id } }); }), });注意toggle和delete里的权限校验不能只看传进来的 id 就操作必须确认todo.userId等于当前会话用户 id。只看前端隐藏按钮没有任何安全性因为请求可以被直接构造服务端校验才是底线。我见过不少半路出家写全栈的同学在这上面翻车特地说一下。ctx里为什么有db和session这要回到src/server/api/trpc.ts里的createTRPCContext它会把 Prisma client 和 NextAuth session 一并注入。正常脚手架生成后你不需要改它但理解它很重要因为你在路由里写任何逻辑都依赖ctx提供的能力。3.3 第三步页面与客户端调用页面用 App Router 结构。为了让表单交互更流畅我把表单做成客户端组件把列表做成服务端组件其实列表可以用 tRPC 客户端拉取但服务端组件里直接查 Prisma 更直观也少一次网络请求。先写一个简单的服务端页面src/app/page.tsximport { getServerSession } from next-auth; import { authOptions } from ~/server/auth; import { TodoList } from ./_components/todo-list; import { TodoForm } from ./_components/todo-form; export default async function HomePage() { const session await getServerSession(authOptions); return ( main classNamemx-auto max-w-xl p-8 h1 classNametext-2xl font-bold {session ? ${session.user.name} 的待办 : 请登录} /h1 {session ? ( div classNamemt-6 space-y-4 TodoForm / TodoList / /div ) : ( a href/api/auth/signin classNamemt-4 inline-block underline 去登录 /a )} /main ); }然后写TodoList客户端组件用 tRPC 的api.todo.list.useQuery()拉数据use client; import { api } from ~/trpc/react; export function TodoList() { const { data: todos, isLoading } api.todo.list.useQuery(); if (isLoading) return p加载中.../p; if (!todos?.length) return p还没有待办先加一条吧。/p; return ( ul classNamespace-y-2 {todos.map((todo) ( li key{todo.id} classNameflex items-center gap-2 rounded border p-3 ToggleButton id{todo.id} done{todo.done} / span className{todo.done ? line-through opacity-60 : } {todo.text} /span DeleteButton id{todo.id} / /li ))} /ul ); }这里api.todo.list的类型不是手写的而是由node_modules/.prisma/client里的 Prisma 类型自动推导过来的。这就是 t3code 项目里最爽的时刻你在 database schema 里改了字段这里的data类型立刻跟着变写错字段直接红线。3.4 第四步处理乐观更新与失效策略新增 Todo 和删除 Todo 如果只是调完 mutation 再重新拉列表体验会很生硬。React Query 的推荐做法是使用乐观更新或者在 mutation 成功后失效缓存。t3code 我用了后者的简化版因为逻辑清晰、踩坑少。use client; import { useState } from react; import { api } from ~/trpc/react; export function TodoForm() { const utils api.useUtils(); const [text, setText] useState(); const createTodo api.todo.create.useMutation({ onSuccess: async () { await utils.todo.list.invalidate(); setText(); }, }); const submit (e: React.FormEvent) { e.preventDefault(); if (!text.trim()) return; createTodo.mutate({ text: text.trim() }); }; return ( form onSubmit{submit} classNameflex gap-2 input value{text} onChange{(e) setText(e.target.value)} placeholder输入待办内容 classNameflex-1 rounded border px-3 py-2 / button typesubmit classNamerounded bg-black px-4 py-2 text-white 新增 /button /form ); }utils.todo.list.invalidate()会精确失效todo.list这个 key 的缓存然后触发一次重新查询。如果列表同时被多个组件使用它们都会自动更新。不过要注意一点如果用户在网络抖动时点删除invalidata 后页面可能短暂出现旧数据如果项目对一致性要求高可以升级成 mutation 里先改缓存再回滚。t3code 是样板项目我选了更稳的失效策略。4. 常见问题与排查技巧实录这部分是整篇里我最想让你认真读的。前面的内容你照做就能跑通但你在实际项目里遇到的问题大概率集中在下面几个场景。4.1 类型推不出来先别怀疑 tRPC遇到最多的问题是客户端api.xxx.yyy.useQuery()提示没有这个属性或者类型总是unknown。首选中招原因不是代码问题而是Prisma Client 没有重新生成。改动schema.prisma后必须执行一次npx prisma generate并且把开发服务器重启。Next.js 的热更新对新生成的 Prisma 类型支持并不总是即时生效我遇到过改了 schema 后热更新没反应重启后就好了。其次要检查你是不是真的在root.ts里挂载了路由。没挂载的话客户端类型里压根不会出现这一项编译器直接提示找不到。别问我为什么这么清楚问就是我试过一次半天没发现。还有一个容易忽略的点tRPC 客户端代码和appRouter的类型是联动的如果你跑到一半修改了路由的输入输出类型但看到 IDE 不更新试着手动重新跑npm run dev而不是依赖热更新。这听起来像没用的废话但我实测下来这种场景下重启很有效。4.2 水合不匹配Date 序列化大坑首次部署以后我的页面在开发环境跑得好好的一发到生产环境浏览器就开始警告 hydration mismatch。排查半天发现是我直接把new Date()传给客户端组件渲染。服务端渲染和客户端渲染各自执行一次生成的时间字符串可能在毫秒级不同导致 HTML 不一致。解决办法很简单在服务端格式化好再传给客户端或者在前端组件内部使用 useEffect 后再显示时间。如果你用的是 Prisma 查出来的createdAt它在服务端组件里可以直接用toLocaleDateString(zh-CN)格式化后再渲染避免传原始 Date 对象到客户端。另外注意 tRPC 返回的 Date 类型经过序列化后再被 React Query 拿到通常会变成字符串。如果你的代码里对todo.createdAt调用.getTime()十有八九会报错。可以用new Date(todo.createdAt)包一层或者依赖 dayjs 这类库解析。这个细节文档里很少提但几乎所有人都会撞上。4.3 Tailwind 动态类名被摇掉在实现 Todo 的优先级或颜色标签时我一开始这么写const colorClass bg-${priority}-500;结果页面毫无样式。原因是 Tailwind 在编译时会扫描源码里的完整类名字符串bg-${priority}-500这种动态拼出来的类名扫描阶段根本看不到所以不会生成对应的 CSS。解决方案有三种把完整的类名写进 safelist适合类名不多的情况用条件对象配合clsx和tailwind-merge适合逻辑清晰的情况直接把所有可能的类名映射表写死比如const colorMap { high: bg-red-500, medium: bg-yellow-500, low: bg-green-500 }。t3code 里我用第三种因为可维护性最高新颜色加一个 key 就行也不会出现诡异拼接问题。4.4 Prisma 与 Serverless 连接数问题如果你把项目部署到 Vercel 这类边缘环境Prisma 默认的连接方式可能扛不住并发。它每个实例都会持有一个连接流量一大数据库连接数直接爆炸。解决办法有两个方向第一把 runtime 固定成 Node.js不要用 Edge Runtime。在路由文件或next.config.js里确保没有开启runtime edge因为 Prisma 在 Edge 环境运行限制很多。第二生产环境给 PostgreSQL 配一个连接池比如用 Prisma Accelerate 或者直接在 Supabase/Neon 这类托管服务里用内置连接池。SQLite 本地开发很舒服但别拿到生产环境用它更适合单机小应用。我的排错流程是一看日志有没有too many connections二看数据库连接数监控曲线三看是不是每个无服务器实例都在启动时新建连接。确定方向后再改配置而不是盲目调大数据库连接上限。4.5 tRPC 的批量请求与体积控制tRPC 每次调用都会走 HTTP如果用户在页面上同时触发多个独立 query默认会产生多个请求。为了减少往返tRPC 支持 HTTP Batch。create-t3-app 默认开启了这个能力但在客户端代码里你要确保自己没有显式填httpBatchLink之外的链接器。默认链接器长这样httpBatchLink({ url: ${getBaseUrl()}/api/trpc, })用httpBatchLink时多个请求会合并成一个并发请求对弱网环境提升明显。但调试时会有点困惑你在 Network 里看到请求地址带?batch1响应是一个数组这是正常行为不是 bug。体积控制方面tRPC 的代码本身很轻主要体积还是 React Query 带来的。如果你担心打包体积可以在next.config.js里做分包但 t3code 是样板项目我不建议前期过度优化先把功能跑通再说。5. 部署与工程化收尾5.1 部署前必做的四件事本地开发跑通不代表能直接上线。t3code 部署前我每次都会过一遍这四件事少一件都可能半夜被线上问题叫醒更新数据库本地用 SQLite线上必须切 PostgreSQL别手滑。切换时要改.env里的DATABASE_URL并让 Prisma 的provider从sqlite改成postgresql。设置新的 NEXTAUTH_SECRET用openssl rand -base64 32重新生成别把开发环境的密钥带到生产。设置正确的 NEXTAUTH_URL指到线上域名否则登录回调会失败。执行prisma migrate deploy这一步要放在应用启动前或者用构建阶段完成后手动执行确保线上表结构是最新的。我后来还加了一步构建前跑一次npx tsc --noEmit和npm run lint。类型检查能拦住大部分低级错误lint 能拦风格问题两者都过了再进构建流程。5.2 生产环境的 Prisma 迁移如果直接用 Vercel 部署构建流程里可能会执行prisma generate但不会自动跑迁移。正确的做法是在package.json的 postinstall 阶段加一句postinstall: prisma generate然后在部署平台的后台或命令行手动执行npx prisma migrate deploy注意不是migrate devdev会在开发环境自动生成迁移文件而deploy只应用已有迁移不会对比本地 schema 差异避免在生产环境误生成新的迁移文件。这个区别真是踩过坑才记住我第一次直接用migrate dev对生产库执行结果它试图创建迁移文件然后失败数据库被锁了一段时间。另外如果你的表结构改动频繁建议在 CI 里专门放一步迁移检查而不是靠人工记得去跑。项目早期人肉记得住项目大了以后十有八九会漏。5.3 一个容易忽略的细节日志与错误追踪tRPC 的错误默认返回到客户端但服务端有没有完整的日志链条是定位线上问题的关键。t3code 里我做的最简单的一步在src/server/api/trpc.ts里给createCallerFactory或路由上下文加上一个中间件把每个请求的路径和耗时打印到服务端日志。export const t initTRPC .contextCreateTRPCContext() .middleware(async ({ path, type, next }) { const start Date.now(); const result await next(); console.log(${type} ${path} took ${Date.now() - start}ms); return result; }) .create();这个简单的中间件成本极低但线上排查时能立刻看出是哪个路由慢、哪个过程报错。再配合部署平台的日志系统基本能满足一个中小项目的调试需求。如果你追求更完整的可观测性可以接入 Sentry但 t3code 这种体量的项目先有一个可靠的日志中间件比啥都强。5.4 不写总结但我还有几句掏心窝的话我实际用 t3code 跑了一个带用户体系的小工具之后最大的感受是类型安全带来的不是“代码短了”而是改代码时胆子大了。以前改一个接口字段要全局搜引用点现在直接改 Prisma model编译器帮我把所有连锁反应指出来。这就是把浪费在“小心翼翼”上的注意力省下来的感觉。另外想分享一个小技巧如果你也在做类似的全栈样板项目可以试着把业务模块坚持用同一个完整流程推演一遍——数据模型、路由、页面、部署。这样做一遍之后你再回看 create-t3-app 自动生成的代码会发现每一行都有它的道理。与其背文档不如亲手把这条水管的每一节都重新接一遍。