t3code:基于T3 Stack的全栈TypeScript类型安全开发模板

发布时间:2026/10/27 16:04:57
t3code:基于T3 Stack的全栈TypeScript类型安全开发模板

1. t3code到底是什么:先聊清楚这个项目要解决的问题

我最初看到“t3code”这个名字的时候,第一反应是它跟T3 Stack有关。实际接触下来,这个名字确实不是一个独立框架,而是一套围绕TypeScript全栈开发的最佳实践集合。更准确地说,t3code是我一直在维护的一个基于T3 Stack思想的开源模板项目,它的核心目标就一句话:让我们能用TypeScript从数据库到前端UI全部打通,全程享受类型提示和编译期检查,尽量减少“改了后端类型导致前端不知不觉就崩了”这类破事。

先解释一下T3这个名词,避免有朋友听得一头雾水。T3 = TypeScript + Tailwind CSS + tRPC。这套组合在2023年到2024年间热度涨得很快,原因也不复杂:它把“全栈类型安全”这件事做到了一个非常舒服的平衡点。你写数据库查询,拿到的是类型明确的结果;你调API,请求参数和响应结构的类型自动从前端标注出来;你改一个字段名,TypeScript编译器会瞬间告诉你哪里忘了同步。这个体验一旦适应了,再回去写传统的前后端分离项目,多少会觉得有些钝。

t3code这套模板解决了什么问题呢?它的定位不是从零教你“Hello World”,而是提供一个在生产环境真正能直接用的起步结构。我见过太多脚手架项目,GitHub星星倒是不少,真正clone下来跑起来才发现缺东少西:没有鉴权、没有数据库迁移脚本、没有错误处理中间件、没有SEO配置、没有通用的响应包装结构。等你把这一堆东西补完,热情基本也耗光了。

用t3code上手的话,你的项目一开始就有这些东西:Next.js 14的App Router结构、tRPC路由和API层、Prisma ORM与数据库迁移配置、NextAuth的GitHub和Google登录、Tailwind的原子化样式体系,外加一套我已经在真实业务里跑过几个月的基础目录规范和错误处理方案。你可以直接在这个基础上开发自己的业务代码,不用再重复造轮子。

这套东西适合谁?两类人最合适:一类是受够了前后端联调时“你等一下我改个字段”的团队,想试试全栈TypeScript的开发节奏;另一类是正在学Next.js和tRPC,需要一个相对完整、代码质量说得过去的项目作为参照的开发者。如果你本来就是传统Spring Boot加Vue的忠实用户,那t3code可能对你吸引力不大,因为整套技术体系的思路跟Java后端那一套差别还是比较远的。

2. 核心设计思路:为什么选择TypeScript全栈这条路

2.1 类型安全到底“安全”在哪里

很多朋友一听到“类型安全”就觉得是噱头,觉得写好测试就完事了。我自己以前也这样想,直到在一次项目迭代里真的被坑惨了。当时后端返回的user.createdAt字段从字符串改成了时间戳数字,前端那个页面用来做日期排序,结果排序逻辑全部失效,而且是运行时才暴露。这种问题在传统前后端分离架构里太常见了:接口的契约靠文档——文档一过期,契约就散了,错误只能靠跑起来才知道。

t3code这套方案不搞文档同步,因为根本不需要文档。数据模型定义在Prisma Schema里,Prisma会根据这个Schema生成完整的TypeScript类型。tRPC把后端路由的函数输入输出类型直接暴露给前端,Next.js组件里调api.user.getById.useQuery(),返回的data.username如果字段写错了,编辑器里直接飘红,根本编译不过。也就是说,出现我之前说的那种日期字段类型变更的问题时,在前端代码编译的这一瞬间就会被发现,根本不会带上线。

这背后的原理说穿了也很朴素:TypeScript是结构类型系统,两个对象只要结构形状匹配,类型就兼容。tRPC利用泛型把后端路由的输入输出类型信息一路传递到前端调用端。Prisma也一样,数据库表结构通过代码生成器变成TS类型。这三层叠加起来,数据库、API、页面UI全部被同一张类型网络覆盖住,数据流走到哪一步,类型检查就检查到哪一步。

2.2 技术选型的取与舍

T3 Stack之所以把tRPC放进来,而不是选REST或GraphQL,有一个很实际的理由:tRPC简单。GraphQL确实强大,但GraphQL的Schema定义、Resolver编写、客户端缓存策略、codegen配置,这一套玩下来学习成本相当可观,很多中小项目根本不需要那么强的灵活性。REST也还行,但写接口路径、处理参数校验、维护跨端类型,明显更费劲。tRPC的做法是直接基于TypeScript函数,后端写一个函数,前端调用这个函数,不需要URL构造,不需要手写请求逻辑,参数和返回值的类型自动同步。

当然,tRPC也不是没有代价。它跟Next.js的Server Components配合时,需要处理好服务端调用和客户端调用的边界,这个稍后我会在实操部分详细演示。另外,如果你的前端客户端不是TypeScript项目,比如小程序用纯JavaScript,那tRPC那套类型推导就发挥不出威力了。好在t3code的目标场景就是全TypeScript,所以基本不存在这个问题。

数据库层选了Prisma而不是TypeORM或者Drizzle,原因也简单:Prisma的Schema语法清晰,迁移工具成熟,类型生成直接集成在workflow里。写一次Schema,建表脚本、TS类型、CRUD操作全部都有了,内置的类型安全做得是整个Node生态里最好的那一档。对于我这种不喜欢反复切换上下文的人来说,这个体验流畅得就像在IDE里有一个隐形的翻译助手。

样式层选Tailwind CSS就不用多说理由了,原子化CSS在组件化开发中的效率优势当年撸过一遍就能体会到。特别是在t3code这种组件多、迭代快的场景里,不用为了一个按钮写一堆CSS类名然后反复切文件,inline的样式工具类直观又高效。

2.3 目录结构的一次到位

t3code的项目结构是经过几轮实战调优的,不是脚手架默认生成的那套原始布局。核心思路是按模块划分,而不是按技术类型划分。你可以理解成“这个功能的所有代码放同一个文件夹”而不是“所有的组件放一个文件夹,所有的API放另一个文件夹”。

比如用户模块,在src/server/api/routers/user.ts里配置tRPC路由,src/server/db/schema.prisma里定义用户表,前端组件文件里直接调用useQuery来取数据。增删改查的过程中,不用去十几层深的目录里找文件,所有关联代码都围绕功能模块就近摆放。这样做最大的好处是代码交接成本低——新同事看项目,顺着路由文件的引用往上翻,很容易理解业务逻辑是怎么串起来的。

3. 实操步骤:从clone t3code到跑通第一个业务接口

3.1 环境准备与前五分钟跑通

这个模板要求Node.js 18以上,包管理器推荐pnpm,因为pnpm的依赖安装速度比npm快,而且磁盘占用小很多。第一步clone项目后,首先要做的事情是安装依赖并把环境变量配好。模板里有一个.env.example文件,复制一份改成.env,然后填入两个关键配置:数据库连接字符串和一个随机生成的安全密钥。

git clone https://github.com/yourname/t3code.git my-project cd my-project pnpm install cp .env.example .env

我这里以PostgreSQL为例,连接字符串长这样:

DATABASE_URL="postgresql://postgres:postgres@localhost:5432/t3code?schema=public" NEXTAUTH_SECRET="your-random-secret-key" NEXTAUTH_URL="http://localhost:3000"

安全密钥可以直接用openssl rand -base64 32生成,别随便输入一串短密码完事,那是很多朋友上线后被攻击的根源。

依赖装好后,跑一次数据库迁移:

pnpm db:push pnpm db:generate

第一条命令是把Prisma Schema同步到数据库,建表建约束;第二条是重新生成Prisma Client的类型代码。跑完之后pnpm dev启动开发服务器,浏览器打开http://localhost:3000,能看到首页样式跑起来,说明模板已经活了。

3.2 从零添加一个“文章”模块的完整流程

好,演示一下真正上手之后怎么做业务开发。这里用最简单的“文章”功能为例,一篇文章有标题、正文、作者和发布时间。

第一步,改Prisma Schema。打开src/server/db/schema.prisma,在User模型下方新增一个模型:

model Post { id String @id @default(cuid()) title String content String author User @relation(fields: [authorId], references: [id]) authorId String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }

代码写好后回到终端,跑pnpm db:push完成表结构同步。

第二步,写tRPC路由。打开src/server/api/routers/post.ts,创建一个新的tRPC router:

import { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; export const postRouter = createTRPCRouter({ list: publicProcedure.query(async ({ ctx }) => { const posts = await ctx.db.post.findMany({ include: { author: { select: { name: true } } }, orderBy: { createdAt: "desc" }, }); return posts; }), create: protectedProcedure .input(z.object({ title: z.string().min(1), content: z.string().min(1), })) .mutation(async ({ ctx, input }) => { const post = await ctx.db.post.create({ data: { title: input.title, content: input.content, authorId: ctx.session.user.id, }, }); return post; }), });

有几个细节我想特别说一下。列表接口我用了publicProcedure,因为是公告类内容,游客也能看;创建文章用了protectedProcedure,这个模板自带登录校验逻辑,未登录直接抛错,不用你自己写鉴权代码。入参校验用的是Zod,z.string().min(1)的意思是必须是非空字符串,如果前端传了个空标题过来,tRPC返回的报错信息会包含精确的校验提示。

第三步,把路由注册到根路由。打开src/server/api/root.ts,把postRouter挂载到appRouter上:

export const appRouter = createTRPCRouter({ post: postRouter, user: userRouter, // ...其他路由 });

第四步,写前端页面。在App Router的src/app/posts/page.tsx里:

"use client"; import { api } from "~/trpc/react"; export default function PostsPage() { const { data: posts, isLoading } = api.post.list.useQuery(); const utils = api.useUtils(); const createPost = api.post.create.useMutation({ onSuccess: () => utils.post.list.invalidate(), }); if (isLoading) return <div>加载中...</div>; return ( <div> <h1>文章列表</h1> {posts?.map((post) => ( <div key={post.id}> <h2>{post.title}</h2> <p>{post.content}</p> <span>{post.author.name}</span> </div> ))} <button onClick={() => createPost.mutate({ title: "测试标题", content: "测试正文", }) } > 发布文章 </button> </div> ); }

注意“use client”这行声明,因为tRPC的useQuery是Hooks用法,必须在客户端组件里使用。如果这个页面同时需要服务端渲染的SEO效果,可以参考t3code模板里专门做的src/app/posts/page.server.tsx和page.client.tsx组合模式,核心思路是页面主体用客户端组件调Hooks,SEO内容放在服务端组件里。

最后,也是很容易被忽略的一步:在src/server/db/schema.prisma里把Post模型跟User模型的posts字段关联起来:

model User { id String @id @default(cuid()) name String? posts Post[] }

这样整套流程就跑通了。浏览器里点击发布按钮,新文章立即写入数据库,页面列表自动刷新,全程类型安全。字段名拼错、参数类型不匹配都是编译时直接报错,连跑起来这个步骤都省了。

3.3 给新模块加权限和错误处理

一个写进的模块如果没有权限控制,基本等于裸奔。t3code模板默认提供的protectedProcedure只能保证用户登录,但要实现“只有作者本人能编辑自己的文章”,还得靠自定义中间件。t3code里可以用tRPC的middleware机制实现:

import { TRPCError } from "@trpc/server"; const canEditPost = t.middleware(async ({ ctx, input, next }) => { const { id } = input as { id: string }; const post = await ctx.db.post.findUnique({ where: { id } }); if (!post || post.authorId !== ctx.session?.user?.id) { throw new TRPCError({ code: "FORBIDDEN" }); } return next({ ctx: { post } }); }); export const protectedPostProcedure = protectedProcedure.use(canEditPost);

这个中间件的思路不太复杂:拿到文章ID之后先把文章查出来,判断作者是不是当前登录用户,是就继续走,不是就抛出403错误。它的意义在于把鉴权逻辑收拢到路由层,业务方法里不掺杂权限判断代码,后面要加“管理员也能编辑别人文章”的规则时,只需要在这个中间件里加一个分支判断。

错误处理方面,t3code里我封装了一个统一错误体,任何路由抛出的异常都被src/server/api/trpc.ts里的onError钩子捕获,按INTERNAL_SERVER_ERROR和UNAUTHORIZED等不同code返回不同提示。这么做的好处是前端拿到的错误信息结构永远是一致的,做全局toast提示时非常顺手。

4. 部署与上线:从本地到生产环境的关键配置

4.1 用Docker把后端跑起来

t3code里我写了一个可以直接用的docker-compose.yml,开发环境下它会启动一个PostgreSQL容器和可选的MinIO对象存储容器。很多人本地开发的时候数据库用的是SQLite,上线前再切换成PostgreSQL,这种做法其实隐患不小。SQLite的锁机制和并发行为跟正式环境完全不同,排查起来浪费的时间远比一开始就配好数据库要多。我的做法是从第一天就用PostgreSQL,在Docker里跑,开发环境和生产环境的环境变量保持一致。

docker compose up -d postgres

跑起来之后再看Prisma的迁移。模板里的schema文件已经定义好了所有模型,直接用pnpm db:push同步即可。真要上线的话,推荐使用prisma migrate deploy,它只执行未应用过的迁移文件,不会重跑已经执行过的脚本。

4.2 部署到Vercel或自建服务器

Next.js项目部署到Vercel基本是零配置的,GitHub上连好仓库,选择根目录,Vercel自动识别Next.js框架,环境变量填上即可。但我需要提醒一个问题:如果你用了tRPC的服务端渲染查询,Vercel的Serverless环境里函数冷启动的延迟会变得更量级显眼。白屏等待的一秒多钟有时候会直接影响用户体验,尤其对To B系统来说,体感差别还挺明显的。

我的建议是,部署目标如果是国内用户群,更稳定的选择是自建一台服务器,用Nginx加PM2或者Docker Compose跑。t3code模板里也带了一份生产环境的docker-compose.prod.yml,里面配了Nginx反代、Next.js容器、PostgreSQL容器三个服务,数据卷持久化也做了。直接在自己的服务器上执行docker compose -f docker-compose.prod.yml up -d --build就能跑起来。如果服务器内存只有1G,记得给Node容器加NODE_OPTIONS=--max-old-space-size=512这样的限制,不然构建时经常被OOM杀掉,这算是新手最容易踩的坑。

4.3 谈谈构建性能的一些经验

t3code这个模板包含的依赖不算少,Next.js生产构建大概需要2分多钟。第一次跑如果发现构建机器挂了,先检查内存。我遇到过一台512M内存的机器跑Next.js build,直接OOM崩溃,加了swap之后勉强能过。提升构建速度的另外一个思路是开启tRPC的babel插件替代方案——直接用SWC,这个Next.js 14默认就是,不需要额外配置。模板里没有引入任何多余的babel插件,这一点对于构建时间的控制相当重要。

最后再提一下环境变量管理的经验。t3code把环境变量都收在.env里,但如果你用了Vercel或者GitHub Actions这样的平台,不要把密钥直接提交到Git仓库的vercel.json里。建议用平台的Secret管理功能或类似方式独立存储,部署时注入进容器环境变量,这样代码仓库里就算别人拿到源码,也拿不到线上数据库的真实密钥。

5. 常见问题与排查技巧:我踩过的坑,你可能也会遇到

5.1 tRPC调用404或者路由不生效

这个问题最容易出现在刚clone完t3code后,我自己也遇到过。查的时候别急着改代码,先确认root.ts里的appRouter是否包含了新加的router,以及src/app/api/trpc/[trpc]/route.ts这个文件是否还保留着默认配置。某些版本的脚手架会生成一个旧的API入口文件,跟App Router的路径冲突,导致tRPC路由没有注册到HTTP Handler上。出现404时,先看网络面板里实际请求的URL是否跟tRPC Route配置一致,再看route.ts有没有被意外删除。

5.2 Prisma的Schema变更之后前端类型没更新

改了schema.prisma之后,光跑db:push还不够,必须再跑一次pnpm db:generate。这个命令会重新生成node_modules/.prisma/client下面的类型声明。很多朋友改了字段名之后,编辑器里前端代码没报错,以为类型没问题,结果一启动就报无法解析,十有八九就是忘了跑generate。

另外,如果你开了多个终端窗口,新类型生成后编辑器可能需要重新加载TypeScript服务才会感知到。VS Code里按下Ctrl+Shift+P,执行“TypeScript: Restart TS Server”就能解决,这个操作我每个月都得用好几次。

5.3 NextAuth的Session在tRPC里拿不到用户信息

这个坑比较隐蔽。t3code的tRPC上下文创建是在createTRPCContext函数里,模板代码里已经用auth()拿了Session。如果自定义调试的时候发现ctx.session是null,先确认两件事:一是浏览器里有没有正确的cookie,二是NextAuth的NEXTAUTH_SECRET有没有配。Node环境里Session拿不到,多数是因为Secret不一致导致JWT验证失败,session直接被视为无效。

还有一点需要记住,protectedProcedure依赖的是ctx.session.user存在,如果你的NextAuth配置里没有设置callbacks.session把用户ID塞进去,那么Prisma查询的时候ctx.session.user.id会是undefined,这个时候数据操作可能报错,但报错位置并不直观,查起来很容易怀疑到数据库头上。

5.4 样式失效或Tailwind的类名不生成

Tailwind JIT模式扫描的是模板文件里出现过的类名。如果你的类名是通过字符串拼接或者外层变量动态生成的,比如const cls = "bg-" + color;,Tailwind在编译时就抓不到这个类名,页面样式就会莫名其妙消失。解决方案也很简单——把完整类名写出来,不要拼接。这个属于Tailwind使用的老生常谈,但确实很多人被卡过。

5.5 线上数据库连接数被打爆

PostgreSQL默认连接数上限是100,Prisma的连接池默认大小跟连接字符串里的参数有关。t3code的模板里DATABASE_URL没有配连接池参数,如果同一时间并发比较高,很容易撞到连接数上限。在生产环境,建议在连接串后面加上?connection_limit=5&pool_timeout=10这样来限制连接池,或者在prisma.ts里显式声明pool配置。不要盲目调大Prisma连接池,数据库的承受能力总是有限的,过大的池子反而会导致连接风暴。

6. 从一个模板到一个习惯:我对t3code的持续维护心得

t3code这个项目我从最早的单仓库模板做到现在,最大的体会是,技术栈本身只是表象,真正有价值的是背后那一整套关于“如何组织全栈TypeScript项目”的认知。

最开始我也觉得tRPC只是个RPC工具,用久了才发现,它逼着你把前后端的数据边界想清楚。哪个数据是服务端拿的,哪个数据是客户端本地缓存的,哪个状态需要实时同步,这些在传统REST下常常被URL模糊掉的问题,在tRPC里反而会因为每个API都是一个函数而变得好梳理。再加上Prisma的类型生成和Next.js的渲染模型,整个项目的数据流是透明的:从数据库表结构到前端页面显示,你完全可以在一张图里画清楚。

还有一点心得是关于“模板应该厚到什么程度”的。t3code之所以带了登录、数据库、错误处理、SEO这些基础设施,是因为绝大多数业务项目根本不需要把这些东西再从头实现一遍。你核心的竞争力一定在业务逻辑里,不在鉴权代码里。快速起一个带完整基础设施的项目,把时间腾出来做业务,是我觉得这个模板存在的最大价值。

最后一个小建议:不管你用什么模板起步,一定花半天时间把模板里每个文件都读一遍。t3code里的代码注释写得比较详细,但框架更新迭代很快,比如Next.js每个大版本都有一些API变动,如果你不主动跟进,半年后再回头跑这个项目,大概率会碰到一堆版本兼容问题。我的经验是给项目固定一个技术栈版本——t3code在根目录的package.json里锁了依赖版本,团队协作时避免“谁能跑谁不能跑”的魔幻局面。你如果自己fork了一套,记得跟着做版本锁定,否则团队成员装依赖的时候很容易装出各不相同的结果来。