t3code 这个项目名,我第一反应就是 T3 Stack 生态里的产物。如果你在 GitHub 或技术社区搜过这个词,大概率会落到 create-t3-app 这个脚手架工具上——一个帮你把 TypeScript、Next.js、tRPC、Prisma、Tailwind CSS 全自动装配好的全栈项目模板。很多开发者会把自己基于这套组合建的仓库直接命名为 t3code,图的就是一个"快、稳、类型安全到底"。
这篇东西我想聊的,不是官方文档的复读,而是作为实际用 t3code 建过几个完整项目的人,把整个技术选型的底层逻辑、每个核心组件的协作方式、从零搭建到生产部署的完整链路,以及我踩过的那些坑,一次性讲透。无论你是刚接触全栈的初学者,还是想给团队快速搭一套内部工具的后端老手,这篇都能帮你少走至少一个月的弯路。
1. T3 Stack 生态的整体设计与选型思路
1.1 为什么会有 t3code 这类项目
我最早知道 create-t3-app,是从一位叫 Theo 的开发者开始的。他在直播里反复强调一个观点:现在的全栈开发,最大的时间黑洞不是写业务逻辑,而是写客户端和服务端之间那层"重复的类型定义"。
你想想,传统模式下前端调后端接口,是不是得先看接口文档,然后手写类型定义,接口一变,前端又得同步改。用 GraphQL 能缓解,但引入 schema、codegen、缓存那一堆概念,对中小项目来说负担并不轻。t3code 这类项目的意义,就是用一个完整的脚手架把"端到端类型安全"这件事变成默认能力。你写一个后端 query,前端的 useQuery 立刻就有对应的类型提示,连字段名拼错编译器都能直接报错,省掉的完全是重复劳动。
这套组合不是随便拼出来的。Next.js 提供服务端渲染和前后端一体的工程结构,tRPC 解决类型安全的 API 调用,Prisma 管数据库层,Tailwind CSS 解决样式迭代速度,TypeScript 是贯穿全局的粘合剂。
1.2 选型背后的取舍逻辑
很多人问我,为什么不用 NestJS 加 REST?为什么不用 Express 加 Mongoose?答案其实很朴实:t3code 适合的场景,是"前后端都由同一个团队甚至同一个人维护"的项目。它强调的是开发效率和类型安全,而不是大团队里严格的接口契约治理。
拿 tRPC 来说,它和 GraphQL 的最本质区别就是:tRPC 没有自己的 schema 语言,所有类型定义都来自 TypeScript。这意味着你不会有两套"真相来源",不会出现服务端改了字段但前端类型没同步的情况。代价是,tRPC 只适合 TypeScript 项目,其他语言想接入就没戏了——但这个代价对个人开发者和中小团队来说根本不算代价。
Prisma 的选择也是同样的思路。它把 SQL 封装成类型安全的查询语句,并且通过prisma generate直接从 schema 生成完整的 client 类型。你定义了一个Post模型,代码里prisma.post.findMany()返回的类型就是自动推断的Post[],DBA 看了想骂人,但对业务开发者来说,这种开发体验真的会上瘾。
整套思路说到底就是一句话:用类型系统尽可能在编译期消灭运行期才会暴露的问题。这也是 t3code 类项目能持续保持热度的根本原因。
2. 核心组件深度拆解:每个零件到底怎么干活
2.1 Next.js App Router 的新协作模式
新版 create-t3-app 默认使用 App Router,而不再用 Pages Router。这个变化在 t3code 项目里体现得特别明显:服务端组件和客户端组件的边界,直接决定了 tRPC 该怎么调用。
先说结论:尽量在服务端组件里直接调用服务端逻辑,只有需要交互式状态更新的时候才用客户端组件调 tRPC query。
服务端组件的写法非常直接,比如你要拉取文章列表,直接在组件里这样写:
import { api } from "~/trpc/server"; export default async function PostsPage() { const posts = await api.post.list(); return ( <div> {posts.map((post) => ( <div key={post.id}> <h2>{post.title}</h2> <p>{post.content}</p> </div> ))} </div> ); }这段代码编译成框架层之后,tRPC的 server caller 会在服务端直接调用 router 的方法,不发 HTTP 请求,不需要序列化和反序列化,性能上比传统的 BFF 模式好很多。同时,因为api.post.list()的返回类型是从 router 里直接推断的,所以posts的每一个字段都自带提示,写错了立刻标红。这种开发体验在旧模式下完全做不到。
但要注意,服务端组件不能用任何事件监听函数,不能有useState,也不存在生命周期。所以一旦你需要在页面上点赞、修改文章、搜索过滤,就必须切换到客户端组件。这种"能服务端处理就不客户端处理"的思路,是 App Router 时代 t3code 项目遵循的第一原则。
2.2 tRPC:让 API 层"隐形"的关键
tRPC 整件事的逻辑可以用一个类比说清楚:如果你家楼上楼下同时住着你自己,你取东西根本不需要打电话再让对方递下来——直接走过去拿就行了。tRPC 在同一个 TypeScript 项目里就是把客户端和服务端放进同一个类型空间,让"函数调用"取代"网络请求"。
在 t3code 项目里,核心的 router 定义通常在src/server/api/root.ts和src/server/api/routers/目录下。每一个 router 由若干个 procedure 组成,procedure 就是具体的 API 端点:
import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; import { prisma } from "~/server/db"; export const postRouter = createTRPCRouter({ list: publicProcedure.query(async () => { return prisma.post.findMany({ orderBy: { createdAt: "desc" } }); }), create: publicProcedure .input(z.object({ title: z.string().min(1), content: z.string().min(1), })) .mutation(async ({ input, ctx }) => { return prisma.post.create({ data: { title: input.title, content: input.content, authorId: ctx.session?.user?.id ?? "", }, }); }), });我没给你省略任何东西,这就是完整的核心链路。客户端调用的时候,你不需要手写 fetch、不需要处理 JSON、不需要关心 URL:
"use client"; import { api } from "~/trpc/react"; export function PostList() { const [posts] = api.post.list.useSuspenseQuery(); const utils = api.useUtils(); const addPost = api.post.create.useMutation({ async onSuccess() { await utils.post.list.invalidate(); }, }); // ... UI 部分 }useSuspenseQuery会返回类型安全的响应数据;useMutation执行完后,invalidate让列表端自动重新拉取,用户看到的新数据不需要你手动刷新。类型错误、字段名错误、参数错误,全在编辑器里直接暴露。API 层这个概念,在 t3code 项目里基本就"隐形"了。
2.3 Prisma:数据库模型即类型源头
Prisma 在 t3code 中的作用,是把数据库的表结构和 TypeScript 类型绑定在一起。你的prisma/schema.prisma就是唯一的模型定义源:
model User { id String @id @default(cuid()) name String? email String @unique emailVerified DateTime? image String? accounts Account[] sessions Session[] posts Post[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } 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 }关键是运行npx prisma migrate dev --name init后,Prisma 会同时完成两件事:一是推送 SQL 变更到数据库,二是重新生成 client 类型。之后你在任意 tRPC procedure 里写prisma.post.findMany(),返回类型就和 schema 完全一致,绝无偏差。
这也是 t3code 项目里最容易出问题的地方,后面讲排查的时候我专门说。
2.4 Tailwind CSS 与 shadcn/ui 组合的迭代优势
Tailwind 在 t3code 里默认被集成到 PostCSS 层,你不用写一个 CSS 文件,直接在 JSX 里堆 class 就行。这套组合对个人项目的核心价值是"放弃上下文切换":你不需要去维护一套独立的样式文件,组件和样式在同一个文件里对应地方改。当你的页面结构频繁调整、原型迭代飞快的阶段,这种模式能省下很多"找选择器"的时间。
如果要做得更快速,t3code 默认还会引入组件库 shadcn/ui。它和其他组件库最大的区别,是它会直接把组件的源码复制到你的项目里,而不是提供一个黑盒依赖。你可以任意改它的样式、逻辑,EA 完全在你的控制之下。Tailwind 加 shadcn/ui 的组合,基本就是"设计感和开发速度"的平衡解。
3. 从零搭建 t3code 项目的完整实操记录
3.1 环境准备与初始化
我先说你前后要准备什么:
- Node.js 18.18 以上版本,建议直接用最新的 LTS。
- 一个包管理器,npm、pnpm、bun 都可以,我推荐 pnpm,依赖安装快很多。
- 如果你要用 PostgreSQL,本机先装好或者用 Docker 起一个;如果你暂时不想装数据库,也能先用 SQLite 起步,代价是生产部署还得再迁。
初始化项目的命令非常简单,跑这条:
npx create-t3-app@latest my-t3code-app注意了,如果你用 pnpm 就全行都不需要npx前缀,直接pnpm create t3-app@latest my-t3code-app。运行后它会问你两个问题,一个是包管理器,另一个是是否使用 TypeScript(默认是,千万别不选),然后就是一套功能勾选:
- Next.js(默认,不可去掉)
- Tailwind CSS(建议保留)
- tRPC(建议保留)
- Prisma(建议保留)
- NextAuth.js(可选,取决于你要不要用户体系)
- App Router(新版默认,不可去掉)
如果你只是先跑通流程,那就全选,它生成的模板是能直接 start 的完整可运行项目。
3.2 环境变量的设置逻辑
初始化完成后第一件事,是把.env.example复制成.env,然后逐个填值。很多人会直接跳过这一步导致后面一堆诡异报错。
DATABASE_URL="postgresql://postgres:password@localhost:5432/my-t3code-app?schema=public" NEXTAUTH_SECRET="随便找个长字符串,建议用 openssl rand -base64 32 生成" NEXTAUTH_URL="http://localhost:3000"NEXTAUTH_SECRET是登录会话加密用的,开发环境随便填一个长字符串就能跑;生产环境必须改成一个环境变量管理的真实随机值。DATABASE_URL如果用 SQLite 就是file:./db.sqlite。
T3 脚手架默认还会在src/env.mjs里用 zod 写一个环境变量校验层。这地方是很多人第一次见到的"反向把关"——你在启动前它就会告诉你环境变量谁没填、谁格式不对,而不是等你跑到某个功能才爆出来。
3.3 定义数据模型并生成 API
数据库表怎么设计不重要,重要的是跑迁移的姿势。我以文章系统为例,把模拟数据先精简一下,设计User和Post两张表,运行两条命令:
npx prisma migrate dev --name init npx prisma db seed跑完以后,数据库表就创建好了,client 也重新生成了。此时如果你想加一个公开接口"最新文章",你不用写额外的 API 路由,直接去src/server/api/routers/post.ts里加一个 procedure,然后前端就能直接调,业务代码不用考虑"这个接口要不要放在另一个服务"。
3.4 前端接入与联调
在前端侧,T3 脚手架会在src/trpc/react.tsx里帮你初始化好api对象,它包含了所有 router 的类型。你在任何客户端组件里import { api } from "~/trpc/react"然后api.post.list.useQuery(),TypeScript 会像变魔术一样告诉你返回数据的完整类型。
我自己走一遍完整流程下来,从初始化到页面上成功显示数据库里的文章,中间几乎不会卡壳。这就是 t3code 的优势:它把最容易出的配置问题全部在脚手架阶段消灭掉了。
4. 常见问题与排查技巧实录
4.1 Prisma Client 类型没有生成
这个是我见过出现频率最高的坑,没有之一。症状是:
Error: query is not defined或者编辑器里prisma.post直接标红。原因基本都是你在改完schema.prisma后没有重新跑npx prisma generate。注意,migrate dev会连带生成,但如果你直接手动改了 schema 而没跑 migration,那 client 类型不会同步。解决办法也很简单:
npx prisma generate或者干脆npm run postinstall——因为 T3 脚手架默认在 postinstall 里挂了prisma generate,你重新npm install它也会重新生成。
4.2 环境变量导致的"server-only"类报错
t3code 项目里所有在src/server/下的代码,默认是import server-only的,意思是你不能在客户端组件里 import 它。如果你非要 import,就会看到Server-only之类的错误。很多新手在服务端路由、db、auth 之间互相引用时踩了这个坑。
解决办法只有一个:严格遵守"服务端内容只在服务端引用"的目录边界。页面组件里如果需要 session 或数据库数据,优先在服务端组件里取好再传 props 给客户端组件。
4.3 tRPC query 缓存不更新
用useMutation改了数据之后,页面上的列表不刷新。这个问题我用大白话解释:tRPC 的 query 默认是走 React Query 的缓存,mutation 成功之后你必须手动让它失效,它才会重新向服务端拉数据。
合规的操作我刚才写过了,就是utils.post.list.invalidate()。如果不做,你只能手动刷新页面,这体验就太差了。
4.4 NextAuth 登录后 session 拿不到 user id
很多 t3code 项目的业务需要当前用户的 ID,比如authorId。如果你发现ctx.session?.user?.id是undefined,十有八九是 Prisma 的 User 模型里没有给id这个字段加映射,或者你在 session callback 里忘了把user.id塞进 session。
你需要检查src/server/auth.ts里的sessioncallback:
session({ session, user }) { if (session.user) { session.user.id = user.id; } return session; }这样 tRPC context 里的 session 就能拿到 user.id 了。