ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

t3code:基于T3 Stack的全栈TypeScript类型安全样板

t3code:基于T3 Stack的全栈TypeScript类型安全样板

如果你最近在刷前端相关社区,大概率会反复看到一个词:T3 Stack。我这边有一个持续维护的小项目,名字就叫t3code,把它当作一套可以直接拿来改、拿来跑、拿来部署的全栈 TypeScript 样板。这个项目的核心不是“造一个新的脚手架”,而是把 Next.js、TypeScript、Tailwind CSS、Prisma、tRPC、NextAuth 这几个东西以最顺手的姿势组合在一起,保证从数据库到页面按钮,类型是通的、逻辑是连的、权限是不漏的。这篇博文我会从 t3code 的设计思路、目录结构、搭建过程、部署经验四个方面展开,适合两类人看:一是想快速起一个带登录、带数据库、带后端接口的全栈应用但不想纠结技术选型的人;二是已经接触过 Next.js 但还没用过 tRPC、Prisma,想找一份完整可跑的代码做参照的人。

1. 为什么我会单独维护一个叫 t3code 的项目

1.1 不是重复造轮子,而是想解决“选型瘫痪”

很多朋友第一次接触 T3 Stack 时都会有个疑惑:Next.js 本身已经能写 API Routes 了,为什么还要引入 tRPC?Prisma 和直接写 SQL 不是差不多?NextAuth 到底比自建 session 省多少事?这些疑问单独看都有答案,但真正上手之后你会发现,最大的成本不是学某个库,而是理解“这几个库放在一起时,数据到底是怎么流动的”。

我最初用传统方案做全栈应用的时候,前后端类型是断的。前端接口返回什么结构,靠手写 interface 去猜;后端改了字段,前端往往要等运行时报错才能发现。时间长了,代码里堆满了any,这不是某个人的习惯问题,而是架构在逼你这么做。后来我接触到 t3code 所依赖的 tRPC 和 Prisma,感受到一种变化:数据库的表结构定义完,Prisma Client 会自动生成类型;tRPC 路由的输入输出用 zod 定义完,前端调用时就能自动拿到完整的类型提示。类型不再是注释,而是一个可以随时编译检查的契约。

t3code 这个名字里的“T3”,实际就是指 T3 Stack 这套技术栈。我把它做成一个独立仓库的目的很简单:当我想快速验证一个产品想法时,不需要重新翻文档回忆“NextAuth 的 callback 应该放在哪”“Prisma schema 里的枚举怎么迁移”,直接克隆 t3code,改几个环境变量和表名,就能在一天之内得到一个带用户体系、带数据库、带 API 的可用应用。

1.2 什么样的项目适合照着 t3code 改

不是所有项目都适合 T3 Stack。在动手之前,我建议你先做一次自我评估。

  • 如果你的产品只是一个静态展示页,没有用户登录、没有数据回写,那用 Next.js 的静态导出就够了,没必要上 Prisma 和 tRPC。
  • 如果你的应用已经是一个成熟的大型 B 端系统,团队里已经有稳定的 Java/Go 后端,那么 t3code 的前后端一体化模式未必能融入现有架构。
  • 但如果你要做的是一款需要快速迭代的 SaaS 工具、内部管理系统、个人作品集、创意项目原型,且你希望一个人或者一个小团队同时掌控前端和后端,那么 t3code 的这套组合非常合适。

我实际用 t3code 做过一个简单的账单管理工具,包含邮箱登录、创建账单分组、记录每笔收支、按月汇总报表。从拿到域名到可以在本地完整跑通,差不多花了一个下午。后面的两周里,我大部分时间都在调整业务逻辑,而不是处理类型对不上的问题。这种“心流”体验在传统前后端分离模式下很难得到。

2. 技术栈骨架:t3code 里最关键的五块积木

2.1 端到端类型安全是如何串起来的

t3code 的最底层是 TypeScript,这一点没什么好争论的。真正让它区别于“只是用了 TS 的项目”的是:类型定义不是每个模块自己维护一份,而是由一个权威来源自动推导。

数据模型层,Prisma Schema 是唯一事实来源。你在 schema.prisma 里定义User、Post这些模型,运行prisma generate之后,Prisma Client 会根据模型自动生成完整的类型。比如查询用户包含哪些字段、创建文章必须传哪些字段,这些在编辑器里都能即时提示。

API 层,tRPC Router 是唯一事实来源。你在服务端定义一个postRouter,内部的create方法用 zod 声明输入(标题必须是字符串,最短一位),用 Prisma Client 操作数据库。当这个 Router 被appRouter汇总并传给前端时,前端拿到的trpc.post.create.useMutation()就会知道自己该传什么参数、返回值是什么结构。中间不会生成任何 OpenAPI Swagger 文档,也不需要你手写interface CreatePostRequest。

我经常用一句话向朋友解释这个架构:数据库是源头,Prisma 把源头变成类型,tRPC 把类型变成接口,Next.js 把接口变成页面。这个链条里的每一环都有类型,所以你能在编译期抓住大部分低级错误。

2.2 Prisma 数据模型和 tRPC 路由的分层逻辑

很多人第一次打开 t3code 的仓库时,会被目录里的server和app两个文件夹搞晕。我在这里做一个通俗的解释:app目录对应“用户能看到什么”,server目录对应“服务器能做什么”。

app目录是 Next.js App Router 管理的,里面是页面和组件。比如app/page.tsx是首页,app/sign-in/page.tsx是登录页。页面组件可以直接调用 tRPC 的 hooks,这些 hooks 会在客户端发起请求。

server目录是 tRPC 和 Prisma 的地盘。server/db.ts导出 Prisma Client 单例,server/auth.ts负责 NextAuth 配置和 session 类型扩展,server/api/trpc.ts负责创建 tRPC 实例和上下文。每个业务模块单独一个 router,比如server/api/routers/postRouter.ts,然后统一汇入server/api/root.ts。

分层的核心原则是:页面不直接操作数据库,数据库操作全部收敛到 router 里。这样做的好处是权限控制只需在 tRPC 中间件里做一次,例如protectedProcedure会先检查 session,如果没有登录直接抛异常,根本不会触达 Prisma。

一个典型的分层调用顺序是这样的:

  1. 页面组件调用trpc.post.list.useQuery();
  2. tRPC 客户端通过 HTTP 把请求发送到 Next.js 的 API 端点;
  3. appRouter根据路由找到postRouter.list;
  4. postRouter.list通过ctx.db执行 Prisma 查询;
  5. 结果按 zod 输出结构返回前端。

2.3 NextAuth 在 SSR 和 CSR 场景下的会话处理

登录认证是全栈应用里最容易出乱子的环节。t3code 把手选择了 NextAuth,并且做了一层额外处理:把 session 信息放进 tRPC 的上下文中,让每个服务端路由都能访问到当前用户。

在server/api/trpc.ts里,createContext会调用getServerSession(authOptions),然后把 session 赋给ctx.session。这样做的好处是,你在写protectedProcedure时直接读ctx.session.user.id就行,根本不需要在页面里手动获取 token 再传给后端。

NextAuth 的配置集中在server/auth.ts。除了传统的邮箱密码登录,我实际测试过 GitHub OAuth 和 Google OAuth,它们只需要在providers数组里加上对应配置,再填环境变量。登录后的用户信息会被映射到 Prisma 的User模型上,由于我们在 Prisma Schema 里定义了Account和Session模型,NextAuth 默认的PrismaAdapter就能正常工作。

需要特别提醒的是,如果你只在客户端渲染的组件里使用useSession,那么刷新页面时会有一段短暂的 loading 状态。如果希望登录状态在 SSR 首屏就确定,可以用app/xxx/page.tsx里的服务端组件直接调用getServerSession,或者在 tRPC 路由里统一判断。t3code 的默认策略是把“是否登录”的判断放在 tRPC 层,而不是在页面层到处判断,这样可以避免漏掉某个入口。

3. t3code 从零到完整运行的搭建流程

3.1 初始化项目并确定技术选型

打开终端,运行:

npm create t3-app@latest t3code

交互界面会让你选择需要的功能。我在一个实际项目里勾选了 Next.js、TypeScript、Tailwind CSS、Prisma、tRPC、NextAuth,数据库暂时选了 SQLite 做本地开发。之所以先选 SQLite,是因为它不需要安装额外的数据库服务,最适合快速跑通链路;当你准备部署时,再切到 PostgreSQL,改动量通常只限于DATABASE_URL和一张迁移表。

初始化完成后,项目结构大致如下:

prisma/ schema.prisma public/ src/ app/ page.tsx layout.tsx api/ trpc/ [trpc]/route.ts server/ auth.ts db.ts api/ trpc.ts root.ts routers/ post.ts styles/ globals.css

看到这个结构的瞬间,我朋友的评价是“这比我自己搭干净多了”。确实,create-t3-app 最大的贡献不是生成代码,而是给出了一套合理的约定。你不需要再纠结“API Routes 放哪”“类型文件放哪”,只要遵循目录约定即可。

3.2 数据模型和核心路由的实现

在 t3code 中,我定义了一个最简单的博客模型,它可以让你验证从建表到前端展示的完整流程:

model User { id String @id @default(cuid()) name String? email String @unique posts Post[] @relation("UserPosts") } model Post { id String @id @default(cuid()) title String content String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt author User @relation("UserPosts", fields: [authorId], references: [id]) authorId String }

初始化后,执行:

npx prisma migrate dev --name init npx prisma generate

这时 Prisma Client 已经能够用prisma.post.findMany()做查询了。在src/server/api/routers/post.ts里,我会写:

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

publicProcedure允许匿名访问,protectedProcedure会先自动检查 session。仅仅这两个词,就替我省掉了很多手写鉴权逻辑。

3.3 前端页面如何调用 tRPC 路由

在src/app/page.tsx里,tRPC 的 hooks 用法非常直观:

"use client"; import { api } from "~/trpc/react"; export default function HomePage() { const { data: posts, isLoading } = api.post.list.useQuery(); if (isLoading) return <div>Loading...</div>; return ( <ul> {posts?.map((post) => ( <li key={post.id}> <h2>{post.title}</h2> <p>{post.author?.name}</p> </li> ))} </ul> ); }

这段代码的关键在于:api.post.list的调用是类型安全的。如果你把list改成getList,编辑器会立刻报错,而不是等你刷新页面才发现 404。这种即时反馈在开发体验上是质的提升。

3.4 环境变量和本地调试配置

t3code 根目录下有一个.env.example文件,复制一份改成.env,然后填好以下变量:

DATABASE_URL="file:./db.sqlite" NEXTAUTH_SECRET="your-secret" NEXTAUTH_URL="http://localhost:3000"

NEXTAUTH_SECRET建议生成一个随机字符串,可以用openssl rand -base64 32。这个变量直接关系到 session 签名的安全性,部署时尤其不能遗漏。

到这里,运行npm run dev,打开http://localhost:3000,你就能看到 t3code 的初始界面。再点击登录,注册一个账号,然后试着创建一篇文章。整条链路跑通之后,你就拥有了一个“麻雀虽小,五脏俱全”的全栈应用。

4. 部署和上线前最容易踩的坑

4.1 数据库迁移和云数据库的选择

本地 SQLite 很愉快,但部署到 Vercel 这种无服务器环境时,文件型数据库没法持久化。我的建议是切换为 PostgreSQL 云数据库,目前我用过的有 Neon、Supabase、Railway,三者都有免费额度,适合个人项目。

切换时把.env的DATABASE_URL换成云数据库的连接串,然后执行:

npx prisma migrate deploy

注意:本地开发时建议用migrate dev,它可以自动创建迁移文件;部署时建议用migrate deploy,它只应用已有的迁移文件,不会在线上环境中自动改 schema。

我在这里踩过一个坑:因为本地数据库里已经有了一些测试数据,直接切换连接串后,migrate deploy在已有的表上执行会报冲突。解决方法很简单,让数据库直接从一个空白库开始,或者把本地旧的迁移记录清理掉。实际部署时,更推荐用prisma migrate reset在测试环境重置,数据库内容不要想当然地复制到生产环境。

4.2 tRPC 请求路径和构建时的类型检查

部署最常见的一个问题,是构建阶段报错。Next.js 在npm run build时会做类型检查,t3code 项目里如果某个 tRPC 路由的返回类型和前端使用处不一致,构建会直接失败。这既是好事也是坏事:好的是你提前发现了错误;坏的是构建速度会受影响。

我的经验是,本地开发时仍然开启完整的类型检查,但 CI 里可以先把prisma generate放在最前面,因为 tRPC 的类型依赖 Prisma Client 生成后的类型。构建命令建议写成:

npx prisma generate && npm run build

如果发现构建内存不足,可以在 package.json 中调整 Node 内存上限,这只是治标;治本是尽量减少页面中不必要的服务端数据请求。

4.3 NextAuth 回调地址的正确写法

NextAuth 的 OAuth 登录在本地能跑,部署后却经常出现“callback URL 不匹配”的问题。其实原因很简单:你在云平台设置的回调地址填的是http://localhost:3000/api/auth/callback/github,但实际线上域名是别的。

t3code 里所有的 OAuth 回调地址都长这样:

https://你的域名/api/auth/callback/github https://你的域名/api/auth/callback/google

如果你用的是 GitHub Provider,那么务必在 GitHub OAuth App 设置里同时填写本地和线上两个回调地址。即使你的NEXTAUTH_URL已经设置为线上域名,平台那边的回调地址不对,依然会跳转失败。

4.4 配置文件里的环境变量泄漏

我在早期把这个仓库分享给朋友时,曾不小心把.env文件提交到了 Git 历史里。虽然很快删除了文件,但历史记录里还有。这个教训很深刻:t3code 这样的全栈项目,仓库里绝对不要提交任何真实密钥。

最佳实践是:

  • 使用.env.example作为模板,只写变量名不写真值;
  • .gitignore里强制忽略.env*;
  • 云端平台的 secrets 单独配置;
  • 大厂提供的免费 PostgreSQL 实例,连接串里的密码也要视为敏感信息。

如果你已经不小心泄漏了,最简单的办法是,在云数据库控制台把密码重置一遍,同时更换 NextAuth 的 secret。

5. 基于 t3code 二次开发的实战建议

5.1 给业务模块重新规划目录

t3code 默认只有一个postRouter,当你开始添加真实业务时,会把它扩展很多。我建议每个业务域一个 router,不要把所有方法都塞进一个大 router 里。例如:

src/server/api/routers/ userRouter.ts billingRouter.ts projectRouter.ts

然后在root.ts中把它们合成一个appRouter:

export const appRouter = createTRPCRouter({ user: userRouter, billing: billingRouter, project: projectRouter, });

这样前端的调用就成了api.project.create.useMutation(),语义清晰,类型也会自动展开。

5.2 测试策略:把业务逻辑从路由中抽出来

我实现在 t3code 里继续写测试时,发现一个规律:tRPC 路由里如果只做“输入校验 + 查库 + 返回”,测试是最好写的。你只需要准备好一个测试数据库,然后直接调用 router 的内部函数,不需要启动 HTTP 服务。

比如postRouter.create依赖ctx.session和ctx.db,我可以在测试里伪造这两个字段:

const ctx = { session: { user: { id: "test-user-id", name: "tester" } }, db: prismaClient, };

然后就调用postRouter.create({ ctx, input: { title: "hello" } })。这种测试方式非常轻量,而且由于输入经过 zod 校验,非法参数也容易测。

5.3 性能优化和缓存策略

有人担心 tRPC 会不会比 REST 慢。我的实测结论是,在绝大多数 SaaS 场景下,性能瓶颈根本不在 tRPC 的协议层,而在数据库查询和页面渲染方式。t3code 里可以按需引入 React Server Component 来减少客户端请求次数,也可以对频繁读取的数据做轻量缓存。

我比较推荐的做法是:如果某个列表短时间内不会变化,就在客户端查询里使用staleTime控制重新获取频率。

const { data } = api.post.list.useQuery(undefined, { staleTime: 60 * 1000, });

这样就避免了每次切换页面都重复拉取相同的数据。

5.4 从 t3code 出发的三种扩展方向

扩展方向完全取决于你的业务类型。

第一种是往“管理和权限”方向扩展。t3code 自带 session,你可以给 User 模型增加role字段,然后在 tRPC 中间件里判断角色,实现管理员和普通成员分离。

第二种是往“文件上传”方向扩展。Next.js 的 Route Handler 可以直接接受 upload,或者接入云存储服务。前端用 tRPC 拿到上传凭证,再把文件直传到对象存储桶,这样不会阻塞服务器。

第三种是往“多租户”方向扩展。给核心业务表增加一个tenantId字段,在 tRPC 的上下文中读取当前用户所属租户,所有查询自动带上这个条件。t3code 的代码结构对这种改动是友好的,因为所有数据库操作都收敛在 router 层,而不是散落在页面各处。

我自己在扩展 t3code 到第二个实际项目时,最大的体会是:这套技术栈并不会帮你把业务变简单,但它能把技术债控制在一个人也能轻松驾驭的范围内。对于个人开发者和小团队来说,这比什么都重要。

最后再分享一个我实际操作中的习惯:每当我准备为一个新功能写一个“通用的、封装好的”组件或函数,都会先问自己一句——这个东西我真的需要吗?t3code 的价值恰恰在于它默认帮你删掉了很多“看起来需要其实不需要”的配置。如果哪天你发现它不符合你的场景,不要犹豫,直接改,它是一个起点,不是终点。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进