第一次看到 t3code 这个名字,很多人第一反应是:又一个开源新框架?其实它并不是一个独立框架,而是现在前端圈讨论度非常高的 T3 Stack 那套全栈类型安全方案的代码实践代称。T3 取的是 TypeScript、Tailwind、tRPC 三个以 T 开头的关键技术的首字母,再配上 Next.js 作为应用底座,t3code 在我和身边团队眼里,就是把这套技术栈内化之后沉淀下来的一整套工程规范:目录怎么分、数据从哪进从哪出、类型在哪一层统一、前后端契约怎么定、部署怎么走。这篇文章就是把这套东西的来龙去脉、落地细节和踩过的坑完整写出来,无论你是刚接触全栈开发的新手,还是想优化团队协作流程的老手,都能在这里找到可以直接抄作业的部分。
1. t3code 到底是什么:先搞懂它要解决的问题
1.1 T3 Stack 的来龙去脉
t3code 的核心底座是 T3 Stack,这套组合最早由开发者 Theo 在 2022 年前后带火,它的理念非常直接:在一个 Next.js 应用里,用 TypeScript 做全站类型约束,用 Tailwind CSS 解决样式问题,用 tRPC 把前端调用后端接口的过程做成天然类型安全的通道。
为什么叫 T3?因为 TypeScript、Tailwind、tRPC 这三个首字母都是 T,Next.js 作为承载它们的主框架反倒成了背景板。很多人容易误解 T3 是一个新框架,实际上它更像一套"优选组合方案"。T3 官网给过一句很有名的话:"The best way to build a type-safe full-stack web app",直译就是构建类型安全全栈应用的最佳方式。这句话有点狂,但实操下来确实有它狂的资本。
我最初看到这套组合时,内心是有点不屑的。TypeScript 我会,Tailwind 我也用,Next.js 更是熟,再加一个 tRPC 能有多大变化?真正把项目从 REST + axios + JWT 的旧模式切过来后,才发现最大的变化不在某个单一技术,而在整套体系跑起来之后那种"前后端类型处处对齐"的开发体验。它解决的不是单个问题,而是让一整条开发链路里最容易出错的信息传递环节,从人肉保证变成了编译器保证。
1.2 为什么偏偏是这四个,而不是别的
这里逐个拆一下选型逻辑,方便你理解为什么 t3code 会选择这套组合,而不是更传统的 Vue + Koa + MySQL 或者 React + Express + REST。
Next.js 承担的是应用骨架。它有文件路由、SSR、SSG、API Routes,以及后来的 App Router 和 Server Components。对于中小型全栈项目,Next.js 一个应用就能搞定前端页面、服务端渲染、接口逻辑、部署配置,不需要单独维护一个前端项目和一个后端项目,心智负担小很多。尤其适合两个人开发一个内部工具、三个人维护一个面向用户的 SaaS 这种场景。
TypeScript 是这条链路的粘合剂。它的作用远不止"变量有类型",在 t3code 里,类型是前后端共同的契约。你在后端定义了某个输入参数的结构,前端调用时编辑器里能直接提示字段名和类型,漏传、多传、传错类型都被拦截在编译阶段。大量联调时"接口字段写错了""后端返回的字段名和文档不一致"这类问题,从根源上消失。
Tailwind 是样式层面的效率工具。为什么选它而不是写传统 CSS 或者 CSS Modules?核心原因是原子化类名让样式与组件结构保持在同一文件,不需要频繁切换文件、起类名、排查样式污染。配合 JIT 编译,生产包只包含实际用到的类,体积可控。如果你不太习惯纯原子类名,也可以在 t3code 规范里加上组件化的方式封装样式,这点后面细说。
tRPC 是整套方案的灵魂。它不像 REST 那样需要你定义 URL、Method、Request Body、Response Body,再在前后端各维护一套接口文档。tRPC 直接导出一个 router,里面的每个 procedure 就是一个可调用接口,前端拿到同一个类型定义直接调用。它不重新发明传输协议,底层还是 HTTP + JSON,但它把"接口开发"这件事的体验拉到了接近写本地函数调用的程度。
为了更直观看差别,我整理了一个对比表:
| 维度 | 传统 REST + 手动类型 | tRPC |
|---|---|---|
| 接口定义 | URL、Method、参数校验各写一遍 | Router + Procedure 一处定义 |
| 前后端契约 | 手写类型 / 文档 / 生成代码 | 从服务端自动推导到客户端 |
| 参数变更 | 后端改,前端跟着改,还容易漏 | 改了服务端,前端编译直接报错提醒 |
| 联调成本 | 经常需要 mock、抓包、沟通 | 基本跳到编译层就暴露问题 |
1.3 t3code 的另一层含义:代码规范与协作边界
除了技术选型,t3code 在我这里还承载了一层"团队协作约定"的含义。很多团队用 T3 Stack 之后依然写得很乱:有人把业务逻辑写在页面组件里,有人直接在客户端组件里调数据库,有人把 tRPC 当 axios 用,还有人把 Tailwind 类名堆成灾难现场。
所以 t3code 这个实践代称,我习惯把它理解成三层约束:
- 目录约束:src 下面怎么分 modules、pages、server、shared,哪一层放什么代码。
- 数据流约束:客户端组件通过 tRPC 拿数据,服务端组件和 Server Action 才能直接操作 ORM,禁止绕过分层跨层调用。
- 类型约束:所有跨边界的数据结构都必须先定义类型,参数校验统一用 zod,不出现 any 满天飞。
有了这三层约束,t3code 就不只是"用了一些新技术",而是一套可以被新人快速上手、被审核者快速 review、被编译器兜底的工程规范。这也是这篇博文想传递给你的核心价值:不止教会你搭脚手架,更想让你理解每行规范背后的代价和收益。
2. 从零搭建一套 t3code 风格项目:脚手架与目录设计
2.1 create-t3-app 初始化:一分钟起步
搭建 T3 Stack 项目最省力的方式是官方脚手架 create-t3-app。我在本地实测的命令是这样的:
pnpm create t3-app@latest my-t3-app执行之后它会交互式问你选哪些功能模块,常见选项包括 Next.js、Tailwind CSS、Prisma、tRPC、NextAuth、Drizzle。我第一次建项目时全部勾选,结果生成的代码里有大量用不到的示例页面,反而干扰理解。建议你第一次尝试时只勾选核心四项:Next.js、TypeScript、Tailwind、tRPC,后续需要 ORM 或鉴权再手动加入。
初始化完成后的目录结构大致像这样:
my-t3-app/ ├── prisma/ # 数据库模型 ├── src/ │ ├── pages/ # Next.js 页面路由 │ ├── server/ │ │ ├── api/ # tRPC 路由入口 │ │ ├── auth/ # NextAuth 配置 │ │ └── db/ # Prisma Client 实例 │ └── styles/ # 全局样式 ├── .env # 环境变量 ├── next.config.js ├── package.json └── tsconfig.json这个目录结构看起来简单,但它对"什么代码放哪"有很强的引导性。tRPC 相关的文件集中在 server/api,数据库配置集中在 server/db,页面只管渲染和调用,不直接碰 ORM。新进来的人只需要看几个入口文件,就能搞明白项目的数据流,这比注释一百行文档都管用。
2.2 核心依赖与配置文件:为什么依赖这么少
T3 Stack 给人最直观的感受是依赖极少。一个传统的 React 全栈项目往往要装几十个包:axios、react-query、react-hook-form、zod、jotai、redux-toolkit……而一个 t3code 项目核心依赖可能就十来个。原因在于 tRPC 内部帮我们做了大量集成工作。
我随便打开一个 t3code 项目的 package.json,核心依赖大概是这些:
{ "dependencies": { "@tanstack/react-query": "^5", "@trpc/client": "^11", "@trpc/next": "^11", "@trpc/react-query": "^11", "@trpc/server": "^11", "next": "^15", "react": "^19", "superjson": "^2", "zod": "^3" }, "devDependencies": { "typescript": "^5", "tailwindcss": "^3", "eslint": "^9", "prisma": "^6" } }也许你会问,react-query 不是额外依赖吗?是的,但它是 tRPC 客户端整合 React 状态管理的基础。tRPC 的 useQuery、useMutation 本质上是构建在 @tanstack/react-query 之上的,所以你省掉的是手动管理"请求中、成功、失败"状态那一大堆样板代码。
superjson 这个依赖也需要专门说一下。tRPC 默认用 JSON 序列化数据,但 JSON 没法直接表达 Date、Map、BigInt 这类类型。superjson 能把它们序列化成可传输的格式并在另一端还原,于是你在前端拿到后端返回的Date对象时,它就是真正的Date实例,可以直接调.toISOString(),不需要再new Date(item.createdAt)。这个体验在写列表页时间显示时特别舒服。
2.3 目录结构与数据流:一次请求的完整路径
理解 t3code 项目,最重要的是理解一次请求从界面到数据库的完整路径。我在给新人讲这套结构时会画这样一条链路:
React 组件(客户端) ↓ 调用 tRPC 客户端 proxy tRPC 客户端(自动携带类型定义、序列化) ↓ HTTP/2 或 HTTP1.1 JSON 请求 Next.js API Route(/api/trpc 入口) ↓ 转发到 tRPC 服务端 router tRPC Procedure(校验 input、执行逻辑) ↓ 调用 Prisma Client 数据库(SQLite / PostgreSQL / MySQL)在这条链路里,类型安全是贯穿始终的。服务端 router 的返回类型会被自动推导到客户端,你在组件里.useQuery()拿到的数据,IDE 会自动提示字段,不需要任何手写 interface。
举个例子,服务端有一个 todoRouter:
// src/server/api/routers/todo.ts import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; export const todoRouter = createTRPCRouter({ getAll: publicProcedure.query(async ({ ctx }) => { return ctx.db.todo.findMany({ orderBy: { createdAt: "desc" }, }); }), add: publicProcedure .input(z.object({ title: z.string().min(1).max(50) })) .mutation(async ({ ctx, input }) => { return ctx.db.todo.create({ data: { title: input.title, done: false }, }); }), });在客户端组件里可以直接这样调用:
import { api } from "~/utils/api"; export function TodoList() { const { data: todos, isLoading } = api.todo.getAll.useQuery(); if (isLoading) return <p>加载中...</p>; return ( <ul> {todos?.map((todo) => ( <li key={todo.id}>{todo.title}</li> ))} </ul> ); }注意几个细节:api.todo.getAll.useQuery()这个调用链路,实际上把"路由名 + 入参类型 + 返回值类型"全部绑定在了一起。如果后端的getAll返回类型改了,比如从Todo[]改成了{ list: Todo[] },前端这段代码会立刻编译报错,提醒你去处理页面取值逻辑。这个过程不需要任何额外工具,纯靠 TypeScript 的类型推断。
这种体验的实际意义在于,它大幅减少了前后端定义的漂移。传统 REST 架构里,前端 interface 和后端实体是两个独立的定义,维护成本高,还经常出现"后端加了字段,前端文档没更新"的情况。t3code 里所有跨端类型都只存在于一行一源的定义中,改一处,处处生效。
3. 核心功能实战:一套可复现的 CRUD + 权限 + 部署
3.1 数据模型先行:用 Prisma 定义业务实体
t3code 项目里我习惯先从 Prisma schema 开始,因为数据模型是整个系统的骨架。这里用一个待办事项的场景演示,麻雀虽小但五脏俱全。
// prisma/schema.prisma generator client { provider = "prisma-client-js" } datasource db { provider = "sqlite" url = env("DATABASE_URL") } model Todo { id String @id @default(cuid()) title String done Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt userId String user User @relation(fields: [userId], references: [id]) } model User { id String @id @default(cuid()) name String? email String @unique todos Todo[] }这里我选了 SQLite 做演示,因为零配置、可随时清掉重来。但在真实项目中,生产环境通常会换成 PostgreSQL 或 MySQL,开发环境保持 SQLite 也完全没问题,Prisma 的迁移机制可以平滑切换。
定义完模型后执行:
npx prisma migrate dev --name init这条命令会做两件事:生成一个 SQL 迁移文件并应用到数据库,同时重新生成 Prisma Client 类型。执行完迁移后,你会在客户端代码里获得完整的类型提示,比如ctx.db.todo.create会提示必须传title、done、userId等字段。
这里有个细节很容易踩坑:改了 Prisma schema 之后,一定要重新生成 Prisma Client。只要迁移执行成功,类型就会更新,但如果你的 dev server 还在跑,有些编辑器不会立刻刷新类型,最常见的现象是明明改了字段但代码里还是报"字段不存在"。解决方法很简单,要么重启 dev server,要么手动执行npx prisma generate。
3.2 用 tRPC 编写后端路由:从工具函数到业务逻辑
tRPC 的 router 组织方式推荐按业务域拆分,而不是做一个巨大的全局 router。比如 todo 相关逻辑放todoRouter,用户相关逻辑放userRouter,然后在上层合并:
// src/server/api/root.ts import { createTRPCRouter } from "~/server/api/trpc"; import { todoRouter } from "~/server/api/routers/todo"; import { userRouter } from "~/server/api/routers/user"; export const appRouter = createTRPCRouter({ todo: todoRouter, user: userRouter, }); export type AppRouter = typeof appRouter;这里AppRouter类型是整个前后端类型安全的关键。客户端正是通过这个类型自动推导出所有可调用的路由和返回类型。请不要在使用 any 或手动抽象它的结构,保持它从 server 根路由直接推断是最稳妥的。
在 tRPC 中,procedure 分两种:query用于读数据,mutation用于写数据。这个划分不只是语义上的,它会映射到 HTTP 的 GET 和 POST,并且在 React Query 层面,query 会被缓存复用,mutation 在成功后可以主动触发 refetch。
实际开发里我经常建议把业务校验放在 zod input 层,而不是写一堆 if 判断。比如新增 Todo 时,标题不能为空、长度不能超过 50,直接在 input 定义里约束好,调用端传错参数会得到一个非常明确的错误信息:
add: publicProcedure .input( z.object({ title: z.string().min(1, "标题不能为空").max(50, "标题过长"), }) ) .mutation(async ({ ctx, input }) => { ... })这么做的好处是校验逻辑跟随接口定义,后端不用在函数体里再写一遍参数检查,前端也能通过类型推断看到参数约束,联调效率高很多。
3.3 前端调用 tRPC:类型穿透到 UI 组件
接下来是前端调用部分。t3code 项目会在src/utils/api.ts里初始化一个带有类型信息的 tRPC 客户端:
// src/utils/api.ts import { createTRPCReact } from "@trpc/react-query"; import type { AppRouter } from "~/server/api/root"; export const api = createTRPCReact<AppRouter>();然后你需要把 tRPC Provider 挂到应用顶层,通常在_app.tsx或 App Router 的 layout 里完成:
import { api } from "~/utils/api"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { httpBatchLink } from "@trpc/client"; import superjson from "superjson"; const queryClient = new QueryClient(); const trpcClient = api.createClient({ links: [ httpBatchLink({ url: "/api/trpc", transformer: superjson, }), ], }); export function TRPCProvider({ children }: { children: React.ReactNode }) { return ( <QueryClientProvider client={queryClient}> <api.Provider client={trpcClient} queryClient={queryClient}> {children} </api.Provider> </QueryClientProvider> ); }这里直接用/api/trpc作为本地接口地址,开发和生产都通用,不需要配置环境变量区分 baseURL,这一点比传统 axios 方案省心不少。
实际调用长这样:
function useAddTodo() { const utils = api.useUtils(); return api.todo.add.useMutation({ onSuccess: () => { utils.todo.getAll.invalidate(); }, }); }invalidate是 React Query 里的一个机制,它表示"让缓存失效并重新请求"。在 tRPC 的封装下,你不需要手动拼 URL、处理 loading 状态、写错误弹窗,这些都被统一收进了 hook 里。团队协作时,新人只要照着已有代码的模式复制一份,就能很快写出风格一致的接口调用代码。
3.4 接入 NextAuth 做登录鉴权与权限控制
任何真实项目都绕不开登录。t3code 项目里最常见的鉴权方案是 NextAuth。先安装并创建一个配置文件:
// src/server/auth.ts import { NextAuthOptions } from "next-auth"; import GithubProvider from "next-auth/providers/github"; import { PrismaAdapter } from "@next-auth/prisma-adapter"; import { db } from "~/server/db"; export const authOptions: NextAuthOptions = { adapter: PrismaAdapter(db), providers: [ GithubProvider({ clientId: process.env.GITHUB_CLIENT_ID!, clientSecret: process.env.GITHUB_CLIENT_SECRET!, }), ], callbacks: { session({ session, user }) { if (session.user) { session.user.id = user.id; } return session; }, }, };配置完 NextAuth 之后,真正体现 t3code 优势的地方是:tRPC 中间件可以直接读取当前登录用户,并基于它做权限控制。例如只允许登录用户查看自己的 Todo:
import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; export const todoRouter = createTRPCRouter({ getMyTodos: protectedProcedure.query(async ({ ctx }) => { return ctx.db.todo.findMany({ where: { userId: ctx.session.user.id }, }); }), });其中protectedProcedure是一个包装过的 procedure,内部做了会话判断。未登录访问时,会直接返回 UNAUTHORIZED 错误,前端收到这个错误可以统一跳转到登录页。
我强烈建议在项目一开始就把受保护路由和公开路由分开定义。不要先把所有 procedure 都写成publicProcedure,等上线前再补权限,那样你会漏掉一堆接口,而且排查的时候特别痛苦。先定义好publicProcedure、protectedProcedure这两种基础类型,后续写业务时顺手选一个,安全边界从第一天就是明确的。
3.5 部署方案:Vercel 一键部署与 Docker 自托管
t3code 项目部署有三种常见路径,我分别说下适用场景。
Vercel是最省心的方式。如果你的数据库用了托管服务,项目推到 GitHub 后,Vercel 会自动识别 Next.js 项目,配置好构建命令和输出目录,只要在环境变量里填好DATABASE_URL和 NextAuth 相关的密钥即可。每次 git push 到主分支就会自动构建发布,回滚也方便,适合团队快速迭代、产品验证阶段。
Docker 自托管适合对部署环境有更高要求的用户,比如数据必须放在自己机房,或者需要走私有网络。一个基础的多阶段 Dockerfile 长这样:
FROM node:20-alpine AS base WORKDIR /app RUN corepack enable FROM base AS deps COPY package.json pnpm-lock.yaml* ./ RUN pnpm install --frozen-lockfile FROM base AS builder COPY --from=deps /app/node_modules ./node_modules COPY . . RUN npx prisma generate RUN pnpm build FROM base AS runner ENV NODE_ENV=production COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/static ./.next/static COPY --from=builder /app/prisma ./prisma EXPOSE 3000 CMD ["node", "server.js"]这里有三个关键点:
- 构建阶段一定要执行
prisma generate,否则运行时 Prisma Client 缺少数据库模型定义。 - Next.js 需要在
next.config.js中设置output: "standalone",这样构建产物才是一个可独立运行的服务,否则next start依赖整个项目上下文。 - 如果数据库是 SQLite,需要确认数据卷挂载位置,不然容器重建后数据就丢了。
服务器部署还有一种轻量方案,直接用next start跑在云服务器上配合 PM2 守护,适合不想引入容器编排的小项目。总之,部署层面 t3code 并不特殊,它就是一个标准的 Next.js 应用,常规方案都能覆盖到。
4. 常见问题与排查技巧实录
4.1 Next.js RSC 边界问题:"use client"到底该加在哪
T3 Stack 演进到 App Router 时代之后,最大的心智负担就是服务端组件和客户端组件的边界。很多新人写代码会报错:某个组件里用了useState或api.todo.getAll.useQuery(),然后 Next.js 告诉你在服务端组件里不能使用 hooks。
这里有个通俗的理解方式:App Router 下,所有组件默认是服务端组件,只在文件顶部显式写"use client"的组件才会被当作客户端组件打包。
我的经验是:tRPC 的 useQuery 永远只在客户端组件里使用。如果某个页面是从服务端组件读取数据的,就不要用 tRPC,直接通过服务端代码访问 Prisma,或者在服务端组件里调用 tRPC 服务端的 caller。有些团队为了图省事会把整个页面都标记为"use client",当时看起来没啥问题,但你会失去很多服务端渲染带来的性能和 SEO 优势。更好的做法是先想清楚页面里哪些部分是纯展示(服务端组件),哪些部分需要交互(客户端组件),再决定"use client"的粒度。
4.2 tRPC 类型不更新:为什么改了 router 前端还是旧类型
这个问题高发,新手几乎必遇。现象是:你在服务端加了一个新 procedure,回到前端组件里却发现api.todo.xxx没有自动补全,报类型不存在。原因基本是 tRPC 的类型是通过AppRouter从服务端推导到客户端的,而 dev server 和编辑器类型检查进程缓存了旧的类型信息。
解决方法按经验排序:
- 重启 dev server,这一步能解决八成问题。
- 如果还不行,删除
.next目录后重新pnpm dev。 - 再不行,确认是不是引入了循环依赖:比如 root router 引了 todoRouter,todoRouter 又反向引用了 root router 里定义的类型,这种情况会导致类型推导被阻断。
还有一个容易被忽略的点:如果你在src/utils/api.ts里写的是createTRPCReact<AppRouter>(),但AppRouter是从server/api/root导入的,要确保根路由文件本身没有明显错误。TypeScript 在根路由类型报错时会在前端表现为"一切调用都失效"。
4.3 Prisma 迁移在运行时报 database error 的排查
常见报错是Environment variable not found: DATABASE_URL或者Can't reach database server。前者多半是.env文件没有被加载,后者如果是开发环境,先检查数据库服务有没有启动,SQLite 则检查路径权限。
prisma migrate dev这个命令只能在开发环境用,因为它允许重置数据库。如果把迁移命令写到生产环境启动脚本里,很容易因为环境差异导致不可预期的问题。生产环境应该用prisma migrate deploy,它只应用迁移文件,不会做任何确认或重置操作。
我记得有一次部署时反复重启都失败,最后发现是 Docker 镜像里没有把prisma目录复制进去,容器根本找不到 schema 文件。如果你也在容器里跑 Prisma,务必确认镜像包含prisma/schema.prisma和迁移目录,且运行时执行路径正确。
4.4 时间序列化与 superjson 的边界
T3 Stack 使用 superjson 解决了 Date 序列化问题,但它也不是万能的。如果你直接绕过 tRPC,从 Next.js API Route 返回Date对象,前端接收到的依然是一个字符串。只有在 tRPC 管道路径中的传输才能应用 superjson。
另外,Prisma 在 SQLite 上返回的日期类型是真实的Date对象,在 tRPC 链路上能保持类型正确。但如果你用了 PostgreSQL 的 timestamp 类型并启用了timestampWithTimeZone,要注意时区转换。我的建议是:全文统一存储 UTC,展示时再转本地时区,别在数据库层做时区转换。
提示:不要把 tRPC 的"类型安全"理解为"数据校验安全"。类型安全处理的是编译期约束,运行时校验需要靠 zod。如果服务端 procedure 没有定义 input,你传进去任意字段也不会报错,因为 TypeScript 类型在编译后被擦除了。所以输入校验一定不要省,tRPC 的魅力就在于把类型和校验放在同一个定义里,用起来很顺手。
4.5 Tailwind 类名失效与样式不生效的排查思路
Tailwind 偶尔会遇到样式不生效,排查思路非常简单:先确认类名有没有拼错,再确认 Tailwind 的内容扫描配置有没有把对应文件包含进去。
// tailwind.config.js module.exports = { content: ["./src/**/*.{js,ts,jsx,tsx}"], };如果新增页面不在 content 匹配范围内,对应的类名就不会被生成。还有一种情况是类名优先级冲突,比如 Tailwind 的bg-red-500被普通 CSS 规则覆盖,常规解法是检查全局样式文件里有没有对background的高优先级定义。真遇到需要灵活处理的地方,可以用 Tailwind 的任意值语法,比如bg-[#f0f0f0]、w-[calc(100%-2rem)],但不要滥用,太多任意值类名会让可维护性下降。
4.6 NextAuth 在 Vercel 上出现回调地址错误
使用 NextAuth 部署到 Vercel 之后,经常遇到redirect_uri不匹配的报错,原因很简单:你本地用的是http://localhost:3000,Vercel 上是https://xxx.vercel.app,而登录服务商那边的回调地址配置的还是本地地址。
解决办法是在 OAuth 服务商后台把线上域名加进回调白名单,同时把环境变量里的NEXTAUTH_URL设置成线上地址。NEXTAUTH_SECRET必须给一个稳定的长随机字符串,不要用默认值,否则服务重启后会话会失效,用户会被反复踢出登录态。
5. 把这套实践搬进团队:适应场景与落地步骤
5.1 什么时候适合 t3code,什么时候不建议硬上
这套组合不是万能的,我在实际调研和项目体验中总结了一些判断依据。
适合的场景:
- 内部工具、后台管理系统、中小型 SaaS 产品,前后端由同一个小团队甚至一个人维护。
- 快速原型验证和 MVP 阶段,需要在两周内把想法变成可用页面。
- 团队对 TypeScript 有基本掌握,愿意接受"类型即契约"的开发理念。
不适合的场景:
- 已有庞大且稳定的 REST API 体系,团队成员分散在不同项目组,此时改造为 tRPC 需要付出较高的迁移成本。
- 需要大规模对接外部系统,接口五花八门,tRPC 主要解决的是自家前后端契约,外部系统无法共享这些类型定义。
- 团队对 TypeScript 很抗拒,或者前端工程化基础薄弱。t3code 的体验高度依赖类型推断和编辑器支持,如果类型语法本身就让你头疼,这套方案会放大你的痛苦。
注意:选技术方案不是选最热门的,而是选能在团队当前阶段稳定产生收益的。t3code 的收益本质来自减少沟通成本和编译期兜底,这是时间越长越明显的好处,不是上线第一天就能量化的指标。
5.2 我们团队落地时的四步迁移法
如果你决定在现有项目引入 t3code 风格实践,不建议一次推倒重来。我后来帮几个团队做迁移时用的是下面这个四步法:
第一步,先选一个非核心模块做试点,比如内部工具里的报表模块,或者 SaaS 的用户设置页。目标是完整跑通"页面 - tRPC - Prisma - 数据库"链路,同时让团队熟悉新代码组织方式。
第二步,定义项目内的目录规范和代码风格。我参考 t3code 默认结构定了一份精简规范:页面组件放页面目录,业务逻辑沉淀到 tRPC router,跨模块的共享类型放 shared 目录,禁止在页面里直接访问环境变量。
第三步,逐步替换低频模块。从读多写少的模块开始,比如数据字典、配置读取、搜索接口,因为这类接口对一致性要求低,即使切换过程中有小问题也不影响核心业务。
第四步,等团队已经形成肌肉记忆后,再考虑替换登录、权限这类核心模块。这时候前面积累的经验已经能覆盖大部分坑,风险会小很多。
5.3 把类型当作唯一契约:最后的小技巧
最后说一个很多人忽略的细节。t3code 项目的价值重心不在脚手架本身,而在"类型穿透"带来的协作方式改变。实际操作中我发现一个非常有用的小技巧:在创建 Procedure 时,把返回类型定义为显式的 TypeScript 类型或 Prisma 返回类型的封装,而不是依赖隐式推导的凌乱对象。例如:
import { z } from "zod"; import type { Todo } from "@prisma/client"; type TodoWithUser = Todo & { user: { id: string; name: string }; }; export const todoRouter = createTRPCRouter({ getById: publicProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }): Promise<TodoWithUser | null> => { return ctx.db.todo.findUnique({ where: { id: input.id }, include: { user: { select: { id: true, name: true } } }, }); }), });给关键返回值写显式类型的好处是,前端消费方不会因为后端多了一个include字段而导致整体类型形状变得不稳定。你是在定义接口契约,而不是在暴露查询细节。这个习惯一旦养成,类型就真正成了团队内部的文档。
我个人的体会是,t3code 用起来最舒服的时刻,往往不是初见时的新鲜感,而是项目进入迭代期之后:改一个后端字段,全链路自动提示;前端拿到的数据永远符合预期;新人看代码只需要看类型就能理解数据流。这套实践的价值,会在三到六个月的持续迭代里显得越来越明显。如果你正在犹豫要不要全栈转向类型安全路线,我的建议是找一个小的内部模块先试水,半个月内你会自己找到答案。