1. 项目概览与设计思路
1.1 t3code是什么:一个“类型安全优先”的全栈脚手架
“t3code”这个名字,我第一眼看到的时候脑子里就冒出来三个T开头的单词:Type-safe(类型安全)、Testable(可测试)、Tidy(整洁)。到后面做得越深入越发现,这三个T其实就是这套项目模板最核心的价值观。简单说,t3code 是一套以 TypeScript 为底座、以 tRPC 为API层、以 Prisma 为数据层的现代全栈 Web 应用启动模板,它解决了我在过往项目中反复遇到的一个问题:前后端类型割裂、API文档滞后、数据模型一改就要到处同步,开发体验极度割裂。
这个模板解决的核心问题可以总结成一句话:让类型从数据库一路贯通到浏览器端UI。以前我们写一个全栈应用,后端定义好字段、数据库改好表结构、前端再手写对应的 interface,三个地方要同步修改,漏一个就出事。t3code 的做法是——数据库Schema作为唯一事实来源,通过 Prisma 生成类型,再通过 tRPC 让 API 层的输入输出自动推导,前端调用时直接获得完整的类型提示,字段改名、类型变动在编译期就能暴露问题。
它适合谁用?三类人:一是想快速搭建内部工具或 SaaS 原型的全栈开发者,不想在API层写大量重复路由代码;二是团队里已经有 REST 风格 API 但深受类型维护之痛的工程负责人,想尝试更现代的通信方式;三是刚接触现代 TypeScript 全栈生态的同学,想通过一个结构清晰、约定明确的模板快速建立完整的工程认知。我的建议是,至少要有基础的 React 和 TypeScript 知识再上手会比较顺滑,但这套模板本身会带着你理解每一层在做什么。
1.2 技术选型分析:为什么选这些而不是别的
很多人问我,为什么不用 Next.js API Routes + axios 链路的传统方案?答案在于tRPC 解决了多端类型复用问题,而 REST 方案无法优雅地做到这一点。REST 当然不是不好,但它的类型定义需要额外维护 OpenAPI 规范或者手写 types,一旦项目进入中期,接口数量过百之后,维护成本会直线上升。GraphQL 也能解决类型复用的问题,但引入了复杂的 Schema 语言和客户端缓存逻辑,对于大多数中小型项目来说,这个复杂度是不划算的。
具体到 t3code 的组合,我做选型时按层次拆解过:
- Next.js(App Router):负责路由和服务端渲染,生态系统成熟,部署简单。
- TypeScript(strict 模式):全链路类型基础,不严格就意味着白搭。
- tRPC:API层的类型安全核心。它不发明新协议,还是走 HTTP,只是把输入输出的类型推断做完了。
- Prisma:ORM 和数据层类型来源,Schema 即类型,迁移工具链完整。
- Tailwind CSS:原子化样式,团队协作时减少 CSS 命名冲突和上下文切换成本。
- NextAuth.js:认证集成,方便接入各种 OAuth Provider,专注业务逻辑而不是认证底层。
这套组合其实很“激进”,因为它把类型安全推进到了极致:从前端的组件 props,到 API 层的输入输出,再到数据库记录类型,全程没有手工编写任何 interface。这种体验一旦适应了,就再也回不到手工维护类型的老路了。
1.3 目录结构与模块边界
t3code 的目录结构设计遵循了“按层分目录、按功能聚模块”的原则。看一个全栈项目,我习惯先看目录结构,因为结构本身就是架构设计的一部分:
t3code/ ├── app/ # Next.js App Router 路由层 │ ├── api/ # NextApi / App Router 路由(少量边界情况使用) │ └── (routes)/ # 页面路由(登录后可见的应用页面) ├── components/ # React 组件层 │ ├── ui/ # 基础 UI 组件 │ └── modals/ # 弹窗等复合组件 ├── server/ │ ├── api/ # tRPC root 路由与子路由定义 │ │ ├── routers/ # 业务子路由(如 userRouter、postRouter) │ │ └── root.ts # 根路由聚合 │ ├── auth.ts # NextAuth 配置与中间件 │ ├── db.ts # Prisma Client 实例化 │ └── api/trpc.ts # tRPC 上下文与初始化 ├── public/ # 静态资源 └── prisma/ ├── schema.prisma # 数据库 Schema 唯一事实来源 └── migrations/ # SQL 迁移文件关键点是:业务逻辑不写在页面组件里,而是集中在 server/api/routers 中,页面组件只负责调用 tRPC 的 hooks 并渲染结果。这个约束让逻辑可以被单元测试覆盖,也让页面代码从“业务夹生饭”变成了“纯UI描述”,调试成本大幅下降。
2. 核心细节解析与实操要点
2.1 TypeScript 严格模式与路径别名的正确配置
t3code 的 tsconfig.json 是整个类型体系的基石。我最看重的一项配置是"strict": true,以及"noUncheckedIndexedAccess": true。后者很多人会忽略,但它能逼你处理数组/对象索引访问时为 undefined 的情况,这对消除运行时异常非常有价值。
路径别名我用的是@/*映射到根目录,这样无论 import 写在哪一层都不需要搞一串../../../这种面条式路径:
{ "compilerOptions": { "strict": true, "noUncheckedIndexedAccess": true, "baseUrl": ".", "paths": { "@/*": ["./*"] } } }设置好路径别名后,还需要同步更新 ESLint 的 import 规则,否则 eslint-plugin-import 会识别不了。在.eslintrc.cjs中补充settings字段:
settings: { 'import/resolver': { typescript: { project: './tsconfig.json' } } }这套配置做完,所有 import 语句中的相对路径都会自动转为@/server/...这样的别名形式。实际体验中,这个细节对代码可读性的提升是立竿见影的,尤其是团队协作时,其他人一眼就能看出模块归属。
2.2 tRPC 路由设计的生命周期与中间件机制
tRPC 是一种 RPC 风格的类型安全通信方案,它和传统 REST 最直观的区别在 URL 设计上:REST 关心“资源”和“动词”,tRPC 只关心“过程调用”。我在 project 中定义路由时,遵循一个简单规则:能表达动作的都用 mutation,纯读操作用 query。
一个典型的 tRPC 子路由长这样:
import { createTRPCRouter, protectedProcedure, publicProcedure } from "@/server/api/trpc"; import { z } from "zod"; export const postRouter = createTRPCRouter({ list: publicProcedure .input(z.object({ limit: z.number().min(1).max(100).default(10) })) .query(async ({ ctx }) => { return ctx.db.post.findMany({ take: 10, orderBy: { createdAt: "desc" }, }); }), create: protectedProcedure .input(z.object({ title: z.string().min(1).max(100), content: z.string() })) .mutation(async ({ ctx, input }) => { return ctx.db.post.create({ data: { ...input, authorId: ctx.session.user.id }, }); }), });这里有几个细节。publicProcedure和protectedProcedure是两种不同的 Procedure 类型:前者不需要登录即可访问,后者会在调用时先执行ctx.session.user校验,不通过则直接抛 UNAUTHORIZED 错误。尤其值得关注的是input中间层用 zod 做运行时校验。这一点至关重要——因为 TypeScript 的类型检查发生在编译期,但数据从浏览器传到服务器之后到底长什么样,运行时是无法靠“声明”来保证的。用 zod 在 API 入口做一次校验,数据合法性和类型安全才真正闭环。
在 trpc.ts 中,通过createContext把 NextAuth 的 session 注入到每个请求的 context 中,这样所有子路由都能通过ctx.session拿到当前用户信息,而不需要挨个去数据库查:
import { initTRPC, TRPCError } from "@trpc/server"; import { createTRPCContext } from "./context"; export const createTRPCRouter = t.router; export const publicProcedure = t.procedure; const enforceUserIsLoggedIn = t.middleware(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { session: ctx.session } }); }); export const protectedProcedure = t.procedure.use(enforceUserIsLoggedIn);提示:中间件中返回
next({ ctx: { ... } })可以覆盖 context,后续 Procedure 拿到的 ctx 就是增强后的版本。我经常用这个机制注入当前用户、多租户 id、请求链路追踪 ID 等,既减少了重复代码,也让类型自动同步。
2.3 Prisma Schema 设计与迁移策略
Prisma 模型是 t3code 全链路类型的起点。这里有一个设计原则:尽量把约束写在数据库层,而不是依赖应用层校验。举个例子,Post 表我除了在 zod 里限制 title 长度,还会在 Prisma schema 中加上@db.VarChar(100)让数据库本身拒绝超长数据。双保险的好处是,绕过 API 直连数据库的脚本(管理后台、数据修复任务)也无法写入脏数据。
model Post { id String @id @default(cuid()) title String @db.VarChar(100) content String @db.Text() published Boolean @default(false) author User @relation(fields: [authorId], references: [id]) authorId String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([authorId]) }迁移策略上,我强烈推荐prisma migrate dev 作为日常开发主流程。每一次 Schema 变更都会生成迁移 SQL,并且标注文件名的日期时间,回放和审计都很方便。团队协作时,优先合并主分支的迁移文件;如果冲突,宁可删掉本地未提交的迁移文件重来,也不要手工改 SQL——手工改迁移文件是后期数据事故的高发根源。
2.4 Tailwind 配置与设计系统约定
t3code 里 Tailwind 的意义不只是“快速写样式”,更是提供了一套团队可共识的设计变量。在 tailwind.config.ts 中我扩展了颜色和间距语义:
import type { Config } from "tailwindcss"; export default { theme: { extend: { colors: { primary: { DEFAULT: "#0f766e", hover: "#115e59", }, surface: "#f8fafc", }, spacing: { 18: "4.5rem", }, boxShadow: { card: "0 1px 3px rgba(0,0,0,0.1), 0 1px 2px rgba(0,0,0,0.06)", }, }, }, plugins: [], } satisfies Config;配置上的心得是:不要一开始就把所有颜色都抽成变量,只抽出重复出现的核心色和阴影,其余先用 Tailwind 默认色板。等设计稿稳定后,再逐步迁移到自定义 token。过早抽象设计系统,往往造成的是“为了抽象而抽象”的负担。
3. 实操过程与核心环节实现
3.1 环境准备与本地初始化
要跑通 t3code,本地环境需要 Node.js 18+(推荐用 nvm 管理版本)、pnpm 或 npm(我用的 pnpm,因为依赖安装速度和磁盘占用明显有优势)、Docker(用来本地跑数据库),以及尽量保持 Git 版本在较新状态。
我的初始化习惯是先用git clone把模板拉下来,然后立刻删除原有 Git 历史,避免后续业务代码和模板 commit 混在一起:
git clone <repo-url> my-project cd my-project rm -rf .git git init git add . git commit -m "chore: init project from t3code template"之后安装依赖并启动数据库:
pnpm install docker compose up -d postgrest3code 提供了一个docker-compose.yml模板,其中就定义了本地 PostgreSQL 服务的端口、用户名、密码和初始化数据库名。实际执行前需要检查.env文件中的 DATABASE_URL 是否和 docker-compose 中一致,否则会连接失败。
3.2 环境变量配置与数据库初始化
t3code 的.env.example文件里所有键都被配置好了,我们只需要复制为.env并填入实际值。关键的几项有:
DATABASE_URL="postgresql://t3code:t3code@localhost:5432/t3code?schema=public" NEXTAUTH_URL="http://localhost:3000" NEXTAUTH_SECRET="一个足够长的随机字符串" GITHUB_ID="github oauth app 的 client id" GITHUB_SECRET="github oauth app 的 client secret"我第一次配置时犯过一个低级错误:NEXTAUTH_SECRET 直接复制了模板里的示例值,结果部署到线上之后发现所有会话在重启后都失效。这个值的生成可以简单用openssl rand -base64 32完成。
数据库初始化分为两步,走到这里时才真正生成数据表结构:
npx prisma migrate dev --name init npx prisma generatemigrate dev会根据 prisma/schema.prisma 里的模型创建出 SQL 迁移文件并执行,generate则会在 node_modules/.prisma/client 中生成类型完整的客户端。需要记住的是,每当你修改 schema.prisma 后,这两条命令都必须依次执行,否则编码时永远拿不到最新的类型定义。
3.3 启动开发服务器与类型链路验证
执行pnpm run dev后,Next.js 会在 3000 端口启动开发服务器。此时打开浏览器访问http://localhost:3000,就能看到 t3code 的默认落地页。但页面能打开只说明工作了一半,真正需要验证的是“类型链路是真通的”。
我的验证方法是写一个最简单但完整的 tRPC 调用:在 page.tsx 中直接调用一个 query,把返回结果渲染出来。此时页面编辑器里,在.data.title后输入.,VS Code 的自动补全应该给出和 Prisma schema 完全一致的字段列表——这就是“数据库到前端类型贯通”的最直观证明:
import { api } from "@/trpc/react"; export default function Home() { const { data } = api.post.list.useQuery({ limit: 10 }); return ( <main> <h1>当前文章</h1> <ul> {data?.map((post) => ( <li key={post.id}> {post.title} - {post.author.name} </li> ))} </ul> </main> ); }如果这一步能跑通,说明 TypeScript、Prisma、tRPC 三个层次的配置都是正确的。这一步是 t3code 整个开发链路的地基,地基不稳,后面的业务代码再炫也没有意义。
3.4 实现一个最小业务闭环:文章发布功能
纸上谈兵没有意义,用一个真实的业务闭环来演示 t3code 的完整开发流程更有说服力。以“发布文章”为例,整个流程是这样的:
第一步:在server/api/routers/post.ts中定义createmutation,这一步上面已经给出了代码。第二步:在页面里写一个表单组件,用api.post.create.useMutation()发送请求。第三步:编写成功后通过utils.post.list.invalidate()让列表自动刷新。
这里最关键的是最后一步。tRPC 客户端会生成一个utils对象,它相当于“缓存管理器”。当你调用invalidate时,所有关联的 query 缓存都会被标记为失效并重新请求。这样前端无需手动刷新页面,数据就会自动同步:
"use client"; import { api } from "@/trpc/react"; import { useState } from "react"; export function PostForm() { const utils = api.useUtils(); const [title, setTitle] = useState(""); const [content, setContent] = useState(""); const createPost = api.post.create.useMutation({ onSuccess: () => { setTitle(""); setContent(""); void utils.post.list.invalidate(); }, }); return ( <form onSubmit={(e) => { e.preventDefault(); createPost.mutate({ title, content }); }} > <input value={title} onChange={(e) => setTitle(e.target.value)} /> <textarea value={content} onChange={(e) => setContent(e.target.value)} /> <button type="submit" disabled={createPost.isPending}> {createPost.isPending ? "发布中..." : "发布"} </button> </form> ); }这个闭环走完之后你会发现一个很有意思的点:整个过程中你没有写过一行 API 路由代码,没有写过 axios 请求,没有定义过一次接口类型。而前后端之间传递的数据依然是严格类型安全的,任何一处字段名错误都会在编译期直接报红。这就是 t3code 想给开发者的核心体验。
3.5 生产构建与部署要点
本地开发跑通后,部署时有一个坑需要提前规避:Prisma 在某些构建环境下会尝试下载二进制引擎导致失败。在 Dockerfile 或者构建脚本中,最好显式指定引擎类型:
prisma generate prisma migrate deploy next build线上环境建议用migrate deploy而不是migrate dev。前者只会静默执行已经存在的迁移文件,适合生产环境;后者会在本地生成迁移并同步 Schema,不适合在生产环境跑。
如果是部署到 Vercel 这类 Serverless 平台,特别容易遇到一个问题:DATABASE_URL中的localhost会被应用服务器访问——但应用服务器上根本没有本地数据库。所以数据库连接串必须改成指向托管数据库服务(如 Neon、Supabase、Railway)的地址。这个坑我第一次部署时踩得很瓷实:本地一切正常,一上 Vercel 就 500,排查了半天才发现是环境变量问题。
4. 常见问题与排查技巧实录
4.1 类型推断失效排查:为什么我改了数据库但前端还是旧类型结果
这个问题出现的频率排在第一位。大概率是 Prisma Client 没有重新生成。因为 tRPC 的类型是通过 Prisma 的返回值推断出来的,Prisma 客户端还保存着旧版本的模型信息,前端类型自然不更新。
我自己的排查流程是:
- 确认 schema.prisma 改好了且保存。
- 终端执行
npx prisma generate,看输出是否正常。 - 若 IDE 里还显示旧类型,重启 TypeScript 服务(VS Code 里按
Cmd+Shift+P执行 “TypeScript: Restart TS Server”)。 - 还是不行,就删掉
.next和node_modules/.prisma后重新pnpm install && prisma generate。
大多数人走到第 3 步就能解决。记得这一步其实不是 TS 缓存问题,而是 VS Code 的 TS 语言服务对文件监听有时不够灵敏。
4.2 Prisma 报错 “P1001: Can't reach database server”
这个报错是告诉你能连上数据库服务器,但端口或者凭据不对。排查就三件事:
- 是否用 Docker 启动了 PostgreSQL?执行
docker compose ps确认状态。 .env中的 DATABASE_URL 用户名密码端口和 docker-compose 中定义的是否完全一致?包括特殊字符是否需要 URL 编码。- 数据库是否真的初始化过?如果容器是新起的,可能需要
docker compose exec postgres psql -U t3code -c "CREATE DATABASE t3code;"手动创建。
另外,如果你访问的是不为 localhost(或 127.0.0.1)而是容器名(例如db)的地址,本地连接时大多数情况下会失败——因为 Node.js 进程在你的宿主机上,访问localhost才行。
4.3 NextAuth 登录后 session 为空
用 GitHub OAuth 登录时经常遇到:跳转授权完成后,回跳页面上session.user有一半时间是 undefined。排查顺序为:
NEXTAUTH_URL必须和浏览器地址栏的实际 URL 完全一致。本地开发时如果通过http://localhost:3000访问,这个值就不能写成http://127.0.0.1:3000,两者在 OAuth 回调签名校验中是严格区分域名的。- NextAuth 官方文档特别提醒,Next.js App Router 下必须在 auth 配置中显式设置
secret,否则开发环境可能不报错,生产环境会在用户会话校验时随机失败。 - 检查
server/auth.ts中配置的 Provider 回调地址,GitHub OAuth App 设置里的回调地址必须是${NEXTAUTH_URL}/api/auth/callback/github。
4.4 构建失败:部署后提示NEXT_PUBLIC_*变量缺失
Next.js 对NEXT_PUBLIC_前缀的变量有硬性要求:它们必须在构建时就被内联进代码,所以运行时再注入就晚了。如果你把环境变量放在构建完成之后的运行环境(比如 Vercel 的 “Runtime Environment Variables”),它是不会生效的。
这一类变量的值如果在构建时需要用到,就得配置在 Vercel/其他平台的 “Build / Environment Variables” 中。如果确实有“同一个构建产物在不同环境下用不同接口地址”的需求,我建议走纯服务端方案:用 tRPC 的 server 端逻辑读取普通环境变量,前端只通过 API 获取。
4.5 常见问题速查表
| 现象 | 大概率原因 | 解决动作 |
|---|---|---|
| 类型不更新 | Prisma Client 未重新生成 | npx prisma generate并重启 TS Server |
| P1001 数据库连不上 | 数据库没启动 / URL 配置错误 | 检查 Docker 和 .env 的 URL |
| 登录后 session 为空 | NEXTAUTH_URL 不一致 | 修正 URL 完全一致并配置 secret |
| 构建期变量缺失 | NEXT_PUBLIC_ 变量未在构建时注入 | 配置 Build Environment Variables |
| tRPC 返回 401 | 忘记用 protectedProcedure | 业务代码中复制 protectedProcedure |
| 自引用字段类型卡死 | Prisma 自关联模型类型循环引用 | 在 schema 中显式定义关系名称 |
5. 进阶:从个人模板到团队规范
5.1 团队协作时的目录规约
一个人用 t3code 跑起来容易,团队一起用就会遇到边界模糊的问题。这里分享几条我们在实践中沉淀出来的规则:
- 组件层:只允许使用
components/ui下的基础组件和components/modals中的复合组件。任何业务域内的组件(比如 PostCard)都放到components/widgets或页面目录下,防止业务组件被其他模块误用。 - 路由层:每个业务域的 tRPC 子路由对应
server/api/routers下的一个文件。文件不要为了刻意拆小而拆小,一个域一个路由文件,方法太多才考虑按子域拆分。 - 数据访问:禁止在组件和页面中直接调用
ctx.db或prisma.xxx。一切数据库访问必须通过 tRPC procedure。这样做的原因是:统一入口让权限校验、日志、数据缓存都有地方放,不会被绕过去。
这套规约维护成本很低,但对架构腐化的抵抗力非常强。团队里即使有新人,只要遵循目录结构和代码分层约定,都能写出风格一致、可维护的业务代码。
5.2 中间件的进阶玩法:日志、审计与多租户
tRPC 中间件的能力被大多数人低估了。除了权限校验,中间件还能实现请求日志、操作审计、多租户隔离,甚至链路追踪。
我们团队在多租户项目中用过一个租户解析中间件,核心逻辑大概是这样:
const tenantMiddleware = t.middleware(async ({ ctx, next }) => { const tenantId = await resolveTenantFromHost(ctx.headers.host); if (!tenantId) { throw new TRPCError({ code: "FORBIDDEN" }); } return next({ ctx: { tenantId } }); }); export const tenantProcedure = protectedProcedure.use(tenantMiddleware);然后子路由里所有 query 都自动带上tenantId过滤:
list: tenantProcedure.query(async ({ ctx }) => { return ctx.db.post.findMany({ where: { tenantId: ctx.tenantId }, }); });每个请求都会自动调用解析函数,业务代码里再也不用担心“忘了过滤租户”这种数据泄露级别的低级错误。这是我在 t3code 里最喜欢的一个中间件用法,强烈推荐你在自己的项目里试试。
5.3 后续扩展方向建议
t3code 这套模板的骨架是足够稳固的,扩展时我的建议排序是:先加监控与日志、再加CI/CD 流程、之后才是微前端或微服务。
监控层面,接入 Sentry 的 tRPC 中间件只需要几行代码,却能在生产环境事故发生后迅速定位到具体是哪个 procedure 出的问题。CI/CD 层面最少也要做三件事:TypeScript 类型检查、prisma migrate deploy前自动执行prisma generate、生产环境构建参数校验。这三件事我整理成了一个.github/workflows/ci.yml,模板可以直接用。
如果你更偏实验精神,可以看看 t3code 后续和Server Actions的取舍:Next.js 的 Server Actions 能直接在服务端执行函数,不需要 tRPC 层,但缺失了 tRPC 的“中间件+统一错误处理+缓存管理”这套机制。我个人的经验是:页面内简单操作可以用 Server Actions,但凡是会被多个复用组件调用的数据逻辑,都走 tRPC 更稳妥。
6. 踩坑后的几点真话
文章最后,不喊口号,就说点实际操作后的体感。
t3code 最让我上头的不是它省了多少模板代码,而是它吞掉了“前后端类型不一致”这类问题的出现空间。以前做项目,周五下午最怕的就是上线前突然发现接口字段对不上,前端改了而后端没跟上,或者是反过来。用了这套类型链路之后,这类事故出现的概率趋近于零——因为编译阶段就挡住了。
第二个强烈体感是,t3code 对团队协作的隐形约束能力很强。它通过目录和 Procedure 的类型签名,把“数据从哪里来、谁能调谁”固化成了代码层的约束。新人即使看不懂架构文档,看到类型报错也会本能地遵循约束。这一点比任何 Code Review 清单都更有效。
最后,分享一个小技巧:如果你经常初始化项目,不妨把 t3code 压缩包放到 Git 仓库的templates分支里,然后写一个create-project.sh脚本,自动 clone、删历史、装依赖、启动数据库。这个过程全跑通只需要两三分钟,非常舒服。根据我的经验,一次成功的脚手架的标志是:三个月后你再看它,依然能迅速定位到某一层在做什么——t3code 做到了这一点,希望你也能。