t3code 这个项目我前后折腾了将近两个月,从一开始只是想验证 T3 Stack 的端到端类型安全到底能省多少事,到后来干脆把它打磨成一套可以直接拿来开新项目的全栈 TypeScript 脚手架。今天把这套东西从头到尾拆一遍,把里面踩过的坑、想清楚的设计逻辑和实际跑的配置全放出来,给那些准备上手 t3code 或者正在犹豫要不要搞 T3 Stack 的朋友做个参考。
先说结论:t3code 本质上是一套基于 T3 Stack 理念的全栈应用模板,核心组合是 Next.js App Router + TypeScript + tRPC + Prisma + TailwindCSS,外加 NextAuth 做认证、Zod 做校验、Docker 做开发环境。它解决的最大痛点是前后端类型割裂和胶水代码泛滥的问题。传统开发里前端调接口、后端写接口,中间隔着一层 JSON 和一堆手写的类型定义,改个字段经常前后端同步改三道地方,漏一处就是线上事故。t3code 的思路是让 API 调用像直接调用本地函数一样,类型从数据库一路贯通到页面组件,写起来很爽也足够安全。
这篇文章适合两类人。一类是已经用过 Next.js 和 Prisma、想了解 TypeScript 全栈类型安全怎么落地的人,可以直接跳到第三节看路由设计和认证集成;另一类是刚接触 T3 Stack、想找个能跑通的模板做起步的人,建议从头到尾过一遍,我会把每个关键配置为什么这么写都解释清楚。
1. 项目整体拆解:t3code 到底把什么串起来了
1.1 核心需求:为什么非要选 T3 Stack 这套组合
很多人第一次看到 T3 Stack 的反应是"这不就是把一堆热门库叠一起吗"。确实有这层因素,但更核心的原因在于每个库要解决的问题和最终目标是一致的:在整个应用生命周期里尽量消灭隐式的类型断层。
传统前后端分离的流程是:后端用 Postman 调试接口,把响应 JSON 复制出来,手动写成前端 TypeScript 接口。当时看着没什么,但项目一迭代就麻烦。比如后端的user表新增字段avatar,后端返回了这个字段,前端的类型却没人同步,于是代码里user.avatar的调用要么写成any绕过,要么直接报Property 'avatar' does not exist。这种问题靠自觉和 code review 根本堵不住。
t3code 的解决方案是端到端类型安全的调用链:
Prisma Schema -> 数据库表结构 -> zod schema 输入校验 -> tRPC procedure -> React Query hooks -> 页面组件这一整条链路里,只要数据库的schema.prisma更新了,prisma generate跑一遍,tRPC 的output类型就跟着变;页面组件通过api.user.get这类调用拿到的返回值,类型就是 Prisma 根据数据库表自动推断出来的,不需要中间任何手写类型。如果后端改了字段名、删了字段、改了返回结构,前端编译直接红,你还没等联调就发现问题了。
我最早在决定选型的时候对比过GraphQL + codegen的方案,结论是:GraphQL 确实也能做到类型生成,但需要维护 schema 定义和 resolver,调试成本和学习曲线都比 tRPC 高出不少。tRPC 不搞自定义 DSL,而是直接利用 TypeScript 类型推导能力,在 API 层把类型带过去,代码量少一截,心智负担也小得多。
1.2 目录结构与职责边界
t3code 的目录结构沿用了 create-t3-app 的推荐布局,但对模块边界做了更细的拆分:
├── prisma/ │ ├── schema.prisma # 数据库模型定义 │ └── migrations/ # 所有迁移文件 ├── src/ │ ├── components/ # 通用 UI 组件 │ ├── pages/ # Next.js App Router 路由 │ ├── server/ │ │ ├── api/ │ │ │ ├── auth/ # NextAuth 路由 │ │ │ ├── routers/ # tRPC 路由 │ │ │ └── root.ts # 路由汇总 │ │ ├── db.ts # Prisma Client 单例 │ │ └── auth.ts # 认证配置 │ ├── trpc/ │ │ ├── server.ts # 服务端调用链 │ │ ├── client.ts # 客户端调用链 │ │ └── react.tsx # React Query 集成 │ └── styles/ ├── middleware.ts ├── docker-compose.yml ├── .env.example └── package.json这个结构里关键的是server和trpc两个目录的边界。server/api/routers下每个文件对应一个业务域路由,比如user.ts、post.ts、auth.ts;server/db.ts必须导出 Prisma Client 的单例;trpc/server.ts负责创建 tRPC 上下文和初始化调用链。切记不要在components里直接引 Prisma Client 或编写数据库查询逻辑,所有数据访问必须收口到 tRPC 的 procedure 上,否则类型安全和复用性都会打折扣。
我实际把项目跑通后发现一个体会:t3code 的目录看似把东西拆散了,但每层只要遵循单向依赖原则(页面 -> 组件 -> tRPC -> Server -> Prisma),新加一个业务功能只会碰「routers 对应文件 + 页面组件」两处,不用到处翻上下文。“改动局部化”才是这种架构对我最有吸引力的地方。
2. 初始化实操:从零到一搭建 t3code 开发环境
2.1 脚手架创建与依赖版本经验
如果你是想直接复刻整套 t3code 而非从空白项目手写,最省力的起点其实是官方脚手架:
npm create t3-app@latest my-t3code-app交互式命令行里会让你选需要的模块,我推荐以下组合:
- Next.js:开启 App Router(现在新项目默认就是)
- TypeScript:必选
- tRPC:必选
- Prisma:必选
- NextAuth.js:如果涉及登录注册就选
- TailwindCSS:看项目需要,选了你后面写样式会比较顺
- ESLint:必选,我后面把规则改过,默认的 too strict 且有些规则与 App Router 冲突,需要调
- Docker:开发环境选上,配 Postgres 很省心
版本方面要特别留意:tRPC 现在 11.x 和 10.x 的写法差异不小,create-t3-app 默认拉的是最新稳定版,如果你在网上搜教学贴,发现代码对不上,先看对应版本的文档。我踩过最坑的一次是老项目是 tRPC v10 写的,升级到 v11 之后createCallerFactory和router的导入路径全变了,花了一晚上才理清楚。
2.2 docker-compose 配置本地数据库
本地数据库直接用 Docker 起一个 Postgres 是最省事的选择,不用在本机装一堆东西。t3code 里的docker-compose.yml我配置如下:
services: db: image: postgres:16-alpine restart: always ports: - "5432:5432" environment: POSTGRES_USER: t3code POSTGRES_PASSWORD: t3code POSTGRES_DB: t3code volumes: - t3code-data:/var/lib/postgresql/data volumes: t3code-data:对应的.env数据库连接串:
DATABASE_URL="postgresql://t3code:t3code@localhost:5432/t3code?schema=public"启动命令:
docker compose up -d这里有个小细节想提醒你。很多人docker compose起来后连不上数据库,第一反应是改 URL 里的localhost为127.0.0.1,其实大概率是 Postgres 的认证配置问题。如果你用的不是官方镜像而是postgres:16-alpine,默认 auth 是scram-sha-256,DATABASE_URL里的账号密码对不上就会报password authentication failed,这时候先检查环境变量是不是读写一致,不要一上来就怀疑端口绑定。
2.3 Prisma 初始化与首次迁移
Prisma 初始化分两步:先改schema.prisma定义模型,再执行 migrate 生成表。比如一个最基础的用户模型:
generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } model User { id String @id @default(cuid()) email String @unique name String? emailVerified DateTime? password String? image String? accounts Account[] sessions Session[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }执行:
npx prisma migrate dev --name init这个命令会把 schema 变化同步成 SQL 迁移,放进migrations目录,同时还会自动跑一遍prisma generate,更新前端可用的类型定义。注意这里我更喜欢用migrate dev而不是db push,因为前者留下的迁移文件是可追溯的,万一将来要回滚或重新初始化环境,有据可依。开发期用db push图省事,但一旦上到 staging 或生产,迁移文件是必备的。
2.4 环境变量拆分与多环境切换
t3code 里我把环境变量拆成三个文件管理:
.env:本地开发用,DATABASE_URL、NEXTAUTH_SECRET、NEXTAUTH_URL.env.example:提交到仓库的模板,里面只放变量名和示例值.env.production:给生产部署用的,通过 CI 流程注入,不进仓库
多环境切换最常见的坑是:你在.env里改了DATABASE_URL但 Prisma Studio 连的还是旧库。这是因为 Prisma 读.env的优先级依赖当前启动目录和 shell 环境变量,如果 shell 里已经 export 了同名变量,.env就不会覆盖。排查这种问题时,npx prisma migrate status会提示当前连接的数据库,先跑这个确认环境。
3. 核心功能实现:认证、鉴权与数据链路
3.1 NextAuth 集成 tRPC:认证态如何安全地进入调用链
认证这块是 t3code 里最值得细说的部分,因为 NextAuth 的 session 数据要能从页面传到 tRPC 的 procedure 并在里面安全地做鉴权。
先看服务端上下文(src/trpc/server.ts):
import { initTRPC, TRPCError } from "@trpc/server"; import superjson from "superjson"; import { ZodError } from "zod"; import { getServerAuthSession } from "~/server/auth"; import { db } from "~/server/db"; export const createTRPCContext = async (opts: { headers: Headers }) => { const session = await getServerAuthSession(); return { db, session, ...opts, }; }; const t = initTRPC.context<typeof createTRPCContext>().create({ transformer: superjson, errorFormatter({ shape, error }) { return { ...shape, data: { ...shape.data, zodError: error.cause instanceof ZodError ? error.cause.flatten() : null, }, }; }, }); export const createCallerFactory = t.createCallerFactory; export const router = t.router; export const publicProcedure = t.procedure; const enforceUserIsAuthed = t.middleware(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { session: { ...ctx.session, user: ctx.session.user }, }, }); }); export const protectedProcedure = t.procedure.use(enforceUserIsAuthed);核心逻辑一句话:每个 tRPC 请求进来,先通过getServerAuthSession()拿当前登录用户的 session 放到 context 里,后面的每个 procedure 都能从ctx.session里读。protectedProcedure是在publicProcedure基础上包了一层中间件,session 不存在直接抛UNAUTHORIZED。
我在这块的实践心得是:不要在页面组件里依赖全局useSession去做防跳转。也就是别以为"登录用户才能看到这个页面"靠前端路由守卫就够了——真正要保护的数据操作,一律放在protectedProcedure上。前端可以做好的体验层拦截,但服务端一定是最终安全边界。别人绕过前端直接调你的 API 端口,你就知道这个设计多重要了。
3.2 路由示例:带 Zod 输入的完整 CRUD
这里用"文章 Article"为例,把 tRPC + Zod + Prisma 的完整链路串起来。
// src/server/api/routers/post.ts import { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "~/trpc/server"; import { TRPCError } from "@trpc/server"; const PostInputSchema = z.object({ title: z.string().min(1).max(80), content: z.string().min(1).max(10000), published: z.boolean().default(false), }); export const postRouter = createTRPCRouter({ create: protectedProcedure .input(PostInputSchema) .mutation(async ({ ctx, input }) => { return ctx.db.post.create({ data: { ...input, authorId: ctx.session.user.id, }, }); }), getAll: publicProcedure.query(async ({ ctx }) => { return ctx.db.post.findMany({ orderBy: { createdAt: "desc" }, take: 20, }); }), getById: publicProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const post = await ctx.db.post.findUnique({ where: { id: input.id } }); if (!post) { throw new TRPCError({ code: "NOT_FOUND", message: "Post not found" }); } return post; }), });这里面的几个关键设计:
- 输入校验用 Zod:tRPC 在 procedure 里收到
input之前会自动执行 schema 校验,校验失败给到前端的格式是我在errorFormatter里定制的扁平化 mirror 对象,方便表单直接读取。你不用自己在每个 mutation 里手动safeParse。 - 输出类型自动推导:
ctx.db.post.create返回的类型就是 Prisma 根据 schema 推断出来的。前端拿到post对象,字段类型全在,不需要任何额外交互。 - 公共接口要加可达性限制:
getAll的take: 20不是顺手写的,是给公开列表页的数据总量兜底。没有这个限制,哪天表里百万行数据,一个人的请求可能把数据库拖死。放在这个位置,每个调用者都自动收口。
前端调用(以 React Hook 为例):
import { api } from "~/trpc/react"; function CreatePostForm() { const utils = api.useUtils(); const createPost = api.post.create.useMutation({ onSuccess: () => { utils.post.getAll.invalidate(); // 自动刷新列表 }, }); const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { const form = new FormData(e.currentTarget); createPost.mutate({ title: form.get("title") as string, content: form.get("content") as string, }); }; return <form onSubmit={handleSubmit}>...</form>; }api.post.create的类型是直接从服务端路由推导出来的,编辑器里输入api.post.会自动提示create、getAll、getById;传参对象也必须匹配 Zod schema,写错了编译不过。这套体验用习惯之后回不去了,哪怕要付出一些类型计算损耗也是值得的。
3.3 数据库访问的收口原则与事务处理
数据访问的收口原则是从 Urql 时代就验证过的模式:你永远不会后悔把所有访问数据库的路径收敛到一层统一入口里。
t3code 里的做法是,数据库操作必须在 tRPC procedure 里完成。你可能觉得这是废话,但实际项目里经常出现"为了性能直接在某组件里调用 Server Action 或 API route 绕过 tRPC"的冲动,比如写一个POST /api/upload的独立 route。我承认上传文件这类场景独立处理是合理的,但如果是普通 CRUD,建议还是走 tRPC,否则时间一长项目里又会出现"两套接口体系并存"的混乱。
事务处理我记得一个特别典型的教训。刚开始在 t3code 里写创建用户+创建默认项目,用的是两个单独的 procedure 串行调用,结果用户创建成功后默认项目创建失败,用户也能正常注册。后来才明白需要事务:
import { db } from "~/server/db"; const createUserWithDefaultProject = async (userId: string) => { return db.$transaction(async (tx) => { const user = await tx.user.create({ data: { id: userId } }); const project = await tx.project.create({ data: { ownerId: user.id, name: "默认项目" }, }); return { user, project }; }); };不要在 prisma Client 上做多步事务操作,要记得用$transaction包裹。这是新手最容易写错的点,特别是当一个操作涉及两个以上互相依赖的写操作时,不上事务出问题只是时间问题。t3code 的server/db.ts里我默认导出的是没绑任何扩展的原生 Client,写在 procedure 里的事务全都手动包$transaction,这样至少责任是清楚的。
4. 前端类型安全链路与常见跑不通的坑
这部分我会把从 API 到 UI 这条链路里容易被忽略的类型细节展开,再按优先级整理一份我实际改过的排查清单。
4.1 trpc/react 客户端的 Provider 初始化顺序
t3code 根组件里必须有 tRPC 的 Provider 包住整棵组件树,同时 React Query 的 QueryClient 要稳定单例,否则每次重渲染都会新建一个缓存导致请求反复失效。
标准写法:
// src/trpc/react.tsx "use client"; import { createTRPCReact } from "@trpc/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { httpBatchLink } from "@trpc/client"; import React from "react"; import { transformer } from "~/trpc/shared"; export const api = createTRPCReact<AppRouter>(); export function TRPCReactProvider(props: { children: React.ReactNode }) { const [queryClient] = React.useState( () => new QueryClient({ defaultOptions: { queries: { staleTime: 60 * 1000, }, }, }) ); const [trpcClient] = React.useState(() => api.createClient({ transformer, links: [ httpBatchLink({ url: getBaseUrl() + "/api/trpc", headers() { return { "x-trpc-source": "react", }; }, }), ], }) ); return ( <api.Provider client={trpcClient} queryClient={queryClient}> <QueryClientProvider client={queryClient}> {props.children} </QueryClientProvider> </api.Provider> ); }staleTime: 60 * 1000是经验值:60 秒内二次进入页面不会重复请求,同时不会让数据“旧”到用户无法接受。如果项目里数据实时性要求高,可以在具体查询里覆盖staleTime: 0或者调低。Provider 的初始化顺序容易搞反的地方在于:必须先有 QueryClient 再创建 trpcClient,两层的 Provider 嵌套顺序固定。
4.2 常见编译报错与排查速查
整理排序靠前的报错,按出现频率从高到低:
| 报错/现象 | 原因 | 处理 |
|---|---|---|
Property 'xxx' does not exist on type ... | Prisma schema 变了但客户端类型没重新生成 | npx prisma generate |
UNAUTHORIZED被抛出但页面看似已经登录 | session 未注入到 tRPC context | 检查getServerAuthSession()是否成功取到 cookie,必要时打印 session |
| 表单传参类型报错 | Zod schema 与前端入参不匹配 | 用z.object({...})的精确类型,不要用any绕过 |
react-query缓存拿到的还是旧数据 | QueryClient 实例被重新创建 | 用React.useState初始化,保证单例 |
发布到 Vercel 后DATABASE_URL不生效 | 生产环境 env 没配或 Prisma 迁移没执行 | 在 Vercel 项目设置里配置环境变量,构建命令前加prisma migrate deploy |
| tRPC v11 与网上 v10 教程的 import 不一致 | 版本差异 | 确认package.json里 tRPC 版本,按官方文档指引调整写法 |
5. 部署与性能优化:从本地到生产的完整链路
5.1 Vercel 部署 + PostgreSQL 托管库
t3code 部署到 Vercel 的流程很短,核心是三步:
- 代码推 Git 仓库,在 Vercel 导入项目
- 配置环境变量(
DATABASE_URL、NEXTAUTH_SECRET、NEXTAUTH_URL) - 设置构建命令
prisma migrate deploy && next build
这里我强制自己记住一个细节:用prisma migrate deploy而不是prisma migrate dev。dev会触发交互式确认和开发模式的迁移逻辑,不适合 CI/生产环境;deploy则是只应用已有的迁移文件,不会重建或提示交互。
数据库我用的是托管 Postgres 服务,选择标准是:能提供固定的连接串、支持 TLS、有基本的备份能力。连接串里记得加?sslmode=require,否则有些托管商要求强制 TLS 会在prisma generate后连接时报错。
5.2 t3code 中容易拖慢首屏的 3 个位置
全栈框架最容易犯的毛病是“把数据查询写在客户端组件里”。t3code 在做首页列表时,如果是公开的不依赖登录态的数据,优先用 Server Component 直接调 tRPC caller,这样可以减少一次客户端往返:
// app/page.tsx (Server Component) import { api } from "~/trpc/server"; export default async function HomePage() { const posts = await api.post.getAll(); return <PostList posts={posts} />; }api从~/trpc/server导入,它是通过createCallerFactory创建的服务端调用器,这层调用不需要经过 HTTP,直接跑在服务端进程里,数据进 props 给下面组件用。相比客户端useQuery需要「页面加载 → 客户端 JS 执行 → 发起请求 → 渲染」,首屏明显更快。
其它两处常见拖慢位置:
- Prisma 逻辑散落各组件:导致一个页面发多个
findMany。建议在 route 层聚合需要的所有数据,再一次性传到下面。 - 没有给列表页做
take限制:在评论区、日志页尤其明显,忘记写take会让 Postgres 一次性拉几万行数据。我建议所有查询默认加take: 50、按需调大,这是习惯性防御而不是功能设计。
5.3 中间件与路由保护
如果你希望某些路由整页只对登录用户开放,可以在middleware.ts做一层粗粒度拦截。注意这是“体验层”,不是“安全边界”,因为中间件运行在边缘网络,你无法保证它一定覆盖所有路径。
// middleware.ts import { withAuth } from "next-auth/middleware"; export default withAuth({ pages: { signIn: "/signin", }, }); export const config = { matcher: ["/dashboard/:path*", "/settings/:path*"], };这个配置的含义是:访问/dashboard/*或/settings/*时自动检查next-auth.session-tokenCookie,没有就跳登录页。也不要把matcher写成["/((?!api|trpc|_next/static|_next/image|favicon.ico).*)"]这种全量匹配的正则,边缘中间件跑得越少越好,只圈你真需要保护的路由。
6. 基于 t3code 延伸出的模块扩展思路
6.1 加一个文件上传模块时的取舍
t3code 不内置文件上传功能,但几乎所有真实项目都要处理图片、附件。我的扩展思路是:文件本身不直接进 Postgres,而是落对象存储,数据库只存 URL。
多数项目拥有者会在“自建上传 API”和“云存储直传”之间纠结。我的建议是,如果项目还没到每天几万上传量的阶段,直接走云存储的预签名 URL 直传最省事。流程是:
- tRPC 的 mutation 接收文件名、类型、大小等元数据,后端校验通过后向对象存储申请一个预签名上传 URL
- 前端拿到这个 URL 直接 PUT 文件到云存储
- 上传完成后调另一个 mutation 把对象 key 和最终 URL 存到数据库对应记录字段里
好处是文件流量不经过自己的服务器,大文件上传不会拖垮应用实例,坏处是上传逻辑分散在前端。但在真实项目里,文件上传链路的后端瓶颈几乎都集中在网络 IO 和内存占用,把文件流绕开 Node 进程是合理的。
6.2 定时任务和后台队列怎么接
t3code 定位是“标准 Web 应用脚手架”,默认不把队列和定时任务塞进来。但如果你要在 web 项目里做事后通知、定期数据聚合、推送这类功能,我的经验是不要直接在 Next.js 进程里写 setTimeout 或者裸的 cron。原因很简单:serverless 环境函数执行完进程就冻结了,cron 触发完全不可控。
更好的做法是接一个独立的定时触发平台或队列服务,触发点放在一个 tRPC router 后面。比如你的定时任务要生成日报,那么平台按 cron 调你的POST /api/trpc/cron.generateDailyReport接口,这个 procedure 校验一个预设的 API key 后开始跑业务逻辑。这样复用了 t3code 现有的类型安全链路,也无需引入额外的 worker runtime。
6.3 权限管理从“有登录”到“有角色”
NextAuth 默认只提供登录态,真正的业务权限需要额外设计。我给 t3code 写权限时的做法其实并不复杂:给User模型增加role枚举字段(比如USER、ADMIN),然后在 protected procedure 里再加一层adminProcedure:
const requireAdmin = t.middleware(({ ctx, next }) => { if (ctx.session.user.role !== "ADMIN") { throw new TRPCError({ code: "FORBIDDEN" }); } return next(); }); export const adminProcedure = protectedProcedure.use(requireAdmin);这样后续管理后台的每个 mutation 都直接用adminProcedure,根本不会有“忘了加权限判断”的低级漏洞。如果业务需要细粒度到“资源归属校验”,比如只能改自己的文章,再在具体 procedure 内查一次资源 ownerId 与当前用户 id 是否匹配就好。
我在实际项目里见过不少权限漏洞,本质都是“校验逻辑散落在页面组件里,后端却不校验”。t3code 的 middleware 方案把这个机制固化在框架层,后面的人写接口想不加都难。
7. 个人实践中的几条核心经验
这段收尾我想聊几个不太容易写进文档、但在实战中真会影响项目走向的判断。
类型安全不是银弹。t3code 做到端到端类型安全,但类型只保证“形状对”,不保证“业务对”。比如一个联网出错的提示,TypeScript 不会因为你类型正确就自动捕获;一个竞态条件下的数据覆盖更是类型系统的盲区。我在 t3code 里一样要靠 React Query 的staleTime、retry参数做兜底。不要因为类型推导很爽就忘了运行时防御。
模板类的项目不要禁锢自己。t3code 是我给自己定的起步框架,但它不该成为一个教条。如果某个页面只需要一个简单表单,你不必非要包一层 tRPC procedure;如果某个内部工具只需要读一次静态数据,直接用 Server Component 查库就行。架构是为产品服务的,不是反过来。
真实项目里最花时间的往往不是写代码。t3code 真正调试耗时的地方集中在:环境变量在不同部署平台的行为差异、Prisma 迁移在团队多人协作时的冲突、边缘函数与 tRPC context 连接超时的时间长短。代码本身怎么写很少是瓶颈,周边生态的配置才是真正的暗礁。
如果你刚开始用这个模板,我的建议是先把一个最小的完整链路(数据库建表到页面展示)跑通,不要一开始就追求复杂功能。把 tRPC 上下文、认证中间件、Zod 报错格式这三个核心机制吃透,后续加功能会顺手得多。这大概就是我从 t3code 中收获最多的部分。