1. 项目概述:t3code 到底在解决什么问题
先把这个词拆开看:T3 就是那个由 Theo(t3.gg)带火的全栈技术栈组合,核心是 Next.js + TypeScript + Tailwind CSS + tRPC;code 就是代码、项目、实践的意思。所以 t3code 说的本质是一套用 T3 Stack 写出来的全栈应用,或者说围绕这套技术栈展开的开发实践。
我第一次接触这个组合的时候,心里其实是有疑虑的。市面上全栈方案太多了,Next.js 自己有 API Routes,也有很多人用 NestJS 做后端,GraphQL 也在不断迭代,为什么 T3 这套东西能火起来?后来真正跑通一个项目之后才明白,它解决的痛点非常具体:前后端类型割裂。
传统开发模式里,前端写 React,后端写 Node,中间靠 REST API 协议沟通,前端维护一份接口定义,后端维护一份接口实现,再配一份文档。三个东西一旦不同步,线上就出各种灵异 bug。t3code 这类项目的核心主张是:整个应用从数据库到页面组件,TypeScript 类型贯穿始终,改一个字段,编译器在保存的那一刻就告诉你哪里会炸。这个体验一旦习惯了,就回不去了。
这篇文章写给谁?如果你是前端开发,想自己动手搭全栈项目但不想学一套全新的后端框架;如果你已经用过 Next.js 但每次写接口都很痛苦,不知道返回的数据类型到底有没有跟数据库对齐;如果你在找一个适合快速上线小工具、内部系统、个人作品的完整技术方案——那么这篇文章的内容正好是你需要的。我会按照我实际搭建 t3code 项目的顺序,把从环境准备、数据建模、接口书写到部署上线的整个链路拆开讲,重点说清楚每一步为什么这么做,以及哪些地方最容易踩坑。
2. 为什么是这四个技术:T3 组合背后的设计逻辑
2.1 TypeScript 作为唯一的胶水
t3code 项目里,TypeScript 不只是用来给变量标类型的。它是整个应用各层之间沟通的唯一语言。数据库模型是 TypeScript 的类型,接口入参出参是 TypeScript 的类型,前端页面组件消费的也是 TypeScript 的类型。三层共享同一套类型定义,这在代码层面就消灭了一整类联调问题。
你可以把 T3 的理解方式类比成装修房子。传统方式是水电工、木工、油漆工各干各的,最后靠口头交代和图纸对接,经常出现插座被柜子挡住这种问题。T3 的方式是全部用同一套 BIM 模型,改任何一处,其他环节立刻知道影响范围。tRPC 就是那套 BIM 模型,Prisma 生成的类型是底层的精确图纸,Next.js 负责把最终成品呈现出来。
实际开发中,这种类型穿透最直观的好处体现在修改需求上。比如要给用户表加一个 nickname 字段,我只需要修改 Prisma schema,然后跑一次 migrate,整个应用的 TypeScript 编译就会自动发现哪些组件缺这个字段、哪些 API 返回类型要调整。没有 T3 的年代,我得搜索所有引用用户信息的页面,逐个手工排查,还不一定查得全。
2.2 tRPC 为什么比 REST 和 GraphQL 更顺手
很多初学者会问一个灵魂问题:为什么不直接写 REST API?我的回答是:REST 本身没问题,但在全栈 TypeScript 项目里,REST 意味着你要自己维护一套类型和接口的对应关系。写 controller、写 validation schema、写 DTO、写前端请求封装,每个环节都有类型断裂的风险。
tRPC 的做法完全不同。它在客户端写一个调用,就像调用本地函数一样:
const user = trpc.user.getById.useQuery({ id: userId });这个调用不需要手写 URL、不需要手写 HTTP 方法、不需要自己处理响应类型的声明。类型是从服务端 router 直接推导过来的,入参错误、返回类型不符,编译器当场报错。GraphQL 也有类型优势,但 GraphQL 需要写 schema 语言、配置 resolver、处理 data loader 的 N+1 问题,对于一个以快速交付为核心诉求的团队来说,复杂度是明显偏高的。
tRPC 的设计哲学就是"少即是多"——它放弃了一些高度定制化的能力(比如复杂的订阅模型、自定义 query 语言),换来了极低的开发心智负担。我在 t3code 项目里从写第一个 query 到完成十个接口,几乎没有查过任何文档,这跟之前写 Express + Axios 的体验完全不一样。
2.3 Prisma 和 NextAuth:数据与身份的标准答案
Prisma 在这个组合里承担数据库访问层的角色。选它的原因只有一个:类型生成体验业界第一。定义好 schema,跑两条命令,数据库表、TypeScript 类型、客户端查询 API 全部就绪。而且 Prisma Studio 提供了一个可视化界面,可以在开发阶段直接看数据、改数据,排查问题非常方便。
NextAuth 解决的是用户认证。它支持 Google、GitHub、邮箱密码、Credentials 等多种登录方式,最关键是它跟 Next.js 的集成是原生的,不需要在应用里额外维护 session 存储逻辑。t3code 脚手架默认集成了 NextAuth,跑起来之后登录注册就有了一套可用的实现,后期再按需扩展权限逻辑。
这套组合最妙的不是每个工具本身多强大,而是它们之间的配合几乎没有缝隙。Prisma 生成的类型可以直接被 tRPC 的 router 引用,NextAuth 的 session 类型可以直接注入到 tRPC 的 context 里,前端调用接口时天然就知道当前用户是谁。每一个环节都设计得刚刚好,不多不少。
3. 从零搭建:create-t3-app 的完整初始化流程
3.1 环境准备与脚手架安装
先说基础环境。我建议 Node.js 保持在 18 或更高版本,npm 7 以上(用 pnpm 更好,下文会细说)。如果本地还没有安装 pnpm,先装一下:
npm install -g pnpm然后直接创建项目:
pnpm create t3-app@latest t3code-app这一步会进入交互式配置界面,有几个选项需要说明一下:
- 是否启用 NextAuth:如果你的项目需要用户系统,选上。不需要就先不选,后加也可以,但默认选上更省事。
- 是否启用 Prisma:只要项目要存数据,就务必选上。这是 T3 全栈闭环的关键一环。
- 是否启用 Tailwind CSS:这个因人而异,但既然 T3 的招牌就是 Tailwind,建议选上。你可以在 className 里快速开发样式,不用写单独的 CSS 文件。
- 是否启用 tRPC:这是核心,必须选上。
- 是否使用 ESLint / Prettier:我都选了,团队协作时这两个是底线。
cd t3code-app pnpm install pnpm dev执行完这几步,一个带类型安全的全栈项目就在本地跑起来了。打开 localhost:3000,你会看到一个默认首页,脚手架自带了一个完整的"帖子 + 用户"示例,很适合用来理解项目结构。
3.2 项目目录结构解读
创建完项目后,第一件事不是急着写代码,而是把目录结构读透。T3 项目的基础结构是这样的:
prisma/ schema.prisma # 数据库模型定义 src/ server/ api/ routers/ post.ts # tRPC 路由:文章相关接口 user.ts # tRPC 路由:用户相关接口 trpc.ts # tRPC 初始化、context 定义 db.ts # Prisma Client 实例 auth.ts # NextAuth 配置 styles/globals.css app/ page.tsx # 首页 layout.tsx # 全局布局 api/ auth/[...nextauth]/route.ts我建议你在动手前把这个结构在脑子里过一遍,因为 T3 的架构哲学是"约定优于配置":server 层放所有后端逻辑,app 层放所有前端页面。页面上任何数据请求,要么直接调用 tRPC 的 hooks,要么通过 Server Component 直接调用服务端的 router。
这里有个容易忽略的细节:context 的传参是贯穿所有接口的关键。在trpc.ts里定义了 session 信息注入后,每个 router 里都能拿到当前请求的登录状态。这意味着你可以在服务端直接判断"这个用户能不能删这条帖子",不需要在前端做权限校验依赖。
3.3 Prisma 数据建模第一步
初始化项目自带schema.prisma,里面有一个 Post 模型作为示例。我先把它替换成自己的业务模型,这里以一个简单的"任务清单"项目为例:
model Task { 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 tasks Task[] }保存 schema 之后执行:
pnpm prisma migrate dev --name init这一步会做三件事:在数据库里创建对应的表、生成最新的 Prisma Client、同步 TypeScript 类型。今后每次修改模型,都跑这条命令,就能保持数据库和类型定义同步。
值得注意的一点是 ID 默认值。我习惯用cuid()而不是自增数字。原因有两个:第一,cuid 不依赖数据库自增机制,在分布式环境或未来做数据迁移时更安全;第二,cuid 在 URL 中出现不会暴露业务数量,避免别人通过 id=100 猜到你有多少条数据。
4. 核心开发实战:tRPC 接口层到前端页面的完整闭环
4.1 定义第一个 tRPC Router
我们直接在src/server/api/routers/task.ts里写任务模块的接口。tRPC 里的写法非常接近直接写函数,只是用procedure包了一层:
import { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "~/server/api/trpc"; export const taskRouter = createTRPCRouter({ getAll: protectedProcedure.query(({ ctx }) => { return ctx.db.task.findMany({ where: { userId: ctx.session.user.id }, orderBy: { createdAt: "desc" }, }); }), create: protectedProcedure .input(z.object({ title: z.string().min(1).max(100) })) .mutation(({ ctx, input }) => { return ctx.db.task.create({ data: { title: input.title, userId: ctx.session.user.id, }, }); }), toggle: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { const task = await ctx.db.task.findUnique({ where: { id: input.id } }); if (!task) throw new Error("任务不存在"); // 校验所有权,防止越权操作 if (task.userId !== ctx.session.user.id) { throw new Error("无权操作该任务"); } return ctx.db.task.update({ where: { id: input.id }, data: { done: !task.done }, }); }), delete: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { const task = await ctx.db.task.findUnique({ where: { id: input.id } }); if (!task || task.userId !== ctx.session.user.id) { throw new Error("无权删除该任务"); } return ctx.db.task.delete({ where: { id: input.id } }); }), });这段代码值得仔细分析几个点。
第一,protectedProcedure和publicProcedure的区别。前者要求请求必须携带有效登录 session,否则会自动拦截;后者允许匿名访问。任务列表、创建、删除这些操作都必须是登录用户才能做的,所以统一用 protected。
第二,z是 zod 库,是 T3 生态里的参数校验标配。在create接口里定义了title必须是 1 到 100 个字符的字符串,输入不合法时前端会直接收到明确的错误信息,而不是服务端默默吞掉异常。
第三,toggle和delete接口里做了一次所有权校验。虽然前端按钮只有自己的任务才会显示,但接口层面的防护不能省——因为任何用户都可以通过浏览器工具直接调用你的 API。这就是我前面说的"权限判断必须在服务端做"的实际体现。
4.2 把 Router 挂到根配置上
写好的 Router 不能直接用,需要把它注册到根路由里。打开src/server/api/root.ts修改:
import { taskRouter } from "~/server/api/routers/task"; import { createTRPCRouter } from "~/server/api/trpc"; export const appRouter = createTRPCRouter({ task: taskRouter, }); export type AppRouter = typeof appRouter;这里的AppRouter类型是整个前端类型推导的源头。每次新增接口后,前端无需重新打包就能自动获得最新的类型定义。这就是 T3 和传统开发最大的不同——接口类型是编译期推导出来的,不是靠文档手抄的。
4.3 前端页面接入:真正可跑的 TODO 应用
页面部分我用一个组件来展示完整交互。以下代码放在src/app/page.tsx(具体路径取决于你的路由设计):
"use client"; import { api } from "~/trpc/react"; import { useState } from "react"; export default function TaskPage() { const [title, setTitle] = useState(""); const utils = api.useUtils(); const { data: tasks, isLoading } = api.task.getAll.useQuery(); const createTask = api.task.create.useMutation({ onSuccess: () => { setTitle(""); utils.task.getAll.invalidate(); }, }); const toggleTask = api.task.toggle.useMutation({ onSuccess: () => utils.task.getAll.invalidate(), }); const deleteTask = api.task.delete.useMutation({ onSuccess: () => utils.task.getAll.invalidate(), }); if (isLoading) return <div>加载中...</div>; return ( <div className="max-w-lg mx-auto p-6"> <h1 className="text-2xl font-bold mb-4">我的任务</h1> <div className="flex gap-2 mb-4"> <input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="输入新任务" className="flex-1 border rounded px-3 py-2" /> <button onClick={() => createTask.mutate({ title })} disabled={!title.trim() || createTask.isPending} className="bg-blue-600 text-white rounded px-4 py-2" > 添加 </button> </div> <ul className="space-y-2"> {tasks?.map((task) => ( <li key={task.id} className="flex items-center gap-2 border rounded p-3"> <input type="checkbox" checked={task.done} onChange={() => toggleTask.mutate({ id: task.id })} /> <span className={task.done ? "line-through text-gray-400 flex-1" : "flex-1"}> {task.title} </span> <button onClick={() => deleteTask.mutate({ id: task.id })} className="text-red-500 text-sm" > 删除 </button> </li> ))} </ul> </div> ); }注意这行代码:
const utils = api.useUtils();这个工具函数是 tRPC 在 React Query 基础上封装的能力。invalidate()的作用是让某个 query 重新拉取数据。比如添加任务成功后调用utils.task.getAll.invalidate(),任务列表就会自动刷新,不需要手动重新 fetch,也不需要刷新页面。
整个交互里我没有任何关于 HTTP 的痕迹。没有 fetch、没有 axios、没有 URL、没有序列化。这就是 tRPC 给我的开发体验。类型也是完整的——tasks数组里的每一项都有id、title、done字段,写错一个字段名字,TypeScript 在保存时就帮你标红了。
4.4 React Query 带来的缓存收益
这里额外说一个容易被忽略的点:tRPC 的 hooks 底层就是 React Query,这意味着你自动获得了缓存、失败重试、加载态管理、窗口聚焦重新拉取等能力。比如useQuery默认会在浏览器窗口重新聚焦时静默刷新数据,这个特性在多人协作的团队项目里经常能避免"数据怎么不是最新的"这类疑问。
对于刚接触 T3 的新手来说,我建议把 React Query 的缓存策略默认值先跑起来,等熟悉了再做微调。改缓存时间这类参数时,只需要在根trpc.ts里的QueryClient配置项调整,不需要每个页面单独处理。
5. 用户认证与数据库迁移:最容易翻车的两个环节
5.1 NextAuth 配置细节与坑点
T3 脚手架集成的 NextAuth,默认支持 Discord 的 OAuth。但绝大多数项目都用不到 Discord,所以第一步是在src/server/auth.ts里改成自己需要的 Provider。以 GitHub 为例:
import GitHubProvider from "next-auth/providers/github"; export const authOptions = { providers: [ GitHubProvider({ clientId: process.env.GITHUB_CLIENT_ID, clientSecret: process.env.GITHUB_CLIENT_SECRET, }), ], // ...其他配置 };坑点在这里:GitHub 的 OAuth App 在创建时,Authorization callback URL 必须严格填成http://localhost:3000/api/auth/callback/github。我见过无数人因为回调地址漏了/callback/github这个路径,本地0 = 0测试一直跳转失败,浪费一两个小时。生产环境要替换成你的正式域名对应的地址。
还有NEXTAUTH_SECRET这个环境变量。开发模式下脚手架可能帮你生成了一个,但生产环境必须自己重新生成一个强密钥:
openssl rand -base64 32把输出填进.env里。这个密钥负责 session 的签名,如果泄露或者部署后跟本地不一致,用户登录状态会全部失效。
5.2 数据库迁移的三种情况
Prisma 的数据模型不会一次定型,后期调整是常态。我总结三种最常见的迁移场景:
- 新增字段:直接在 schema 里加一行,跑
pnpm prisma migrate dev --name add_new_field,完成。 - 修改字段类型:比如把
String改成Int,需要先确认现有数据是否兼容,否则迁移时数据库会报错,遇到这种情况我习惯先在生产库上备份一次。 - 删除字段:Prisma 会在迁移时要求你确认,因为它会丢弃数据。执行前务必确认该字段没有使用。
每次迁移后,记得重启开发服务器。有些时候迁移命令跑完,Prisma Client 没有完全热更新,导致 IDE 里的类型还是旧的。重启可以解决绝大多数类似的"幽灵问题"。
5.3 生产环境数据库的路
开发时用的是 SQLite 文件,零配置很方便,但生产环境建议换到 PostgreSQL,这样并发能力、数据可靠性都会好很多。Prisma 的datasource改成 PostgreSQL 后,连接字符串形如:
DATABASE_URL="postgresql://user:password@host:5432/dbname"改完后再跑一次pnpm prisma migrate deploy。注意deploy和dev的区别:dev用于开发阶段自动重建和创建迁移文件,deploy用于生产环境只应用现有的迁移,不会重新生成文件。这两个命令我一开始经常用混,后来踩过几次坑才记住。
6. 部署上线:从本地到服务器的完整路径
6.1 使用 Docker 部署的步骤
我自己常用的生产部署方式,是用 Docker 把 t3code 整个应用打包跑在服务器上。写一个最简的Dockerfile:
FROM node:20-alpine AS build WORKDIR /app COPY package.json pnpm-lock.yaml ./ RUN pnpm install --frozen-lockfile COPY . . RUN pnpm prisma generate RUN pnpm build FROM node:20-alpine AS runner WORKDIR /app ENV NODE_ENV=production COPY --from=build /app/.next ./.next COPY --from=build /app/node_modules ./node_modules COPY --from=build /app/prisma ./prisma COPY --from=build /app/public ./public COPY --from=build /app/package.json ./package.json EXPOSE 3000 CMD ["pnpm", "start"]这里有两处容易出问题的地方。
第一,prisma generate必须在 build 阶段执行,否则容器里找不到 Prisma Client。我之前漏过这一步,启动后所有数据库操作直接报PrismaClient is not configured。
第二,.next目录复制要完整。Next.js 的一些资源文件是按需生成的,漏复制会导致运行时某些静态资源 404。实际生产里我建议用 Next.js 官方推荐的 standalone 模式,构建体积更小,但需要额外配置output: 'standalone'。
6.2 环境变量的管理和注入
部署环境里有一堆密钥需要管理:DATABASE_URL、NEXTAUTH_SECRET、GITHUB_CLIENT_ID、GITHUB_CLIENT_SECRET。这些都必须在容器启动时注入。
如果你用 Docker Compose,可以这样写:
services: app: build: . ports: - "3000:3000" env_file: - .env.production depends_on: - db db: image: postgres:16 env_file: - .env.production volumes: - pgdata:/var/lib/postgresql/data volumes: pgdata:我个人坚持一个原则:环境变量文件永远不要提交到 Git 仓库。GitHub 泄露密钥的事故太多了,一次大意,整个项目的数据库和用户数据就全暴露了。.gitignore里务必把.env*加进去,只保留.env.example这种模板文件。
6.3 部署后的验证清单
部署完成后不要急着收工,按这个清单自检一遍:
- 首页能否正常打开,静态资源是否有 404。
- 登录流程是否完整,OAuth 回调地址是否换成了生产域名。
- 数据库是否连接成功后端接口才能正常返回数据。
- 注意查看服务器日志里是否有 NextAuth 的 secret 警告。
- 用手机访问一下,检查响应式布局有没有崩。
- HTTPS 是否正常跳转,没有安全警告。
这些检查往往能提前暴露 80% 的上线事故。我在最初几次部署时,总是在登录这一环被卡住——后来才想起是回调地址没改成生产域名。
7. 常见问题与排查技巧实录
7.1 tRPC 客户端类型不同步
症状:前端写的调用参数明明对的,但 TypeScript 报类型不匹配。
原因:修改了 backend router 类型后,前端没有重新加载类型。开发模式下 tRPC 的类型是自动推导的,但跑在 Node 环境里的类型有时不会立刻更新。
解决办法:先重启开发服务器,再不行就删掉.next目录重新跑一次pnpm dev。对于pnpm用户,还可以试试pnpm dlx tsc --noEmit强制做一次全量类型检查。
7.2 Prisma 查询性能慢
症状:接口响应时间很长,数据量也不大,但查询就是慢。
排查方法:在 Prisma 查询时加上console.log输出执行耗时,然后用数据库的 EXPLAIN 命令分析 SQL 执行计划。最常见的坑是忘写include或使用 n+1 查询。
例如这个查询加载每个用户的所有任务:
const users = await db.user.findMany({ include: { tasks: true }, });如果你只查用户列表,完全不需要加载 tasks,把 include 去掉性能会立竿见影。对于复杂查询,可以借助 Prisma 的relationLoadStrategy: 'query'或其他底层优化工具。我在做一个仪表盘页面时,就是因为每天全量加载所有用户的订单列表,接口直接超时了。改成按需加载 + 分页之后,性能从 8 秒降到 300 毫秒。
7.3 NextAuth 登录后 session 丢失
症状:登录成功跳转后,页面仍显示未登录;刷新后才正常。或者跳转后直接无限重定向。
排查方向:先检查secret是否配置;再检查NEXTAUTH_URL环境变量是否准确,它必须跟你的部署域名保持一致;最后看 cookies 的Secure标志是否在 HTTPS 环境下正常。前端useSession拿不到数据,可以打开浏览器 DevTools 的 Application 面板看 cookies,确认 session 有没有写成功。
7.4 常用问题速查表
| 问题现象 | 大概率原因 | 快速解法 |
|---|---|---|
| 接口 401 | 未登录或 token 过期 | 检查 NextAuth 配置,确认 session 有效期 |
| 接口 500 | 数据库连接失败或代码异常 | 看服务器日志,多查两层错误堆栈 |
| 前端类型报错 | 后端类型未同步 | 重启 dev server,重跑prisma generate |
| 部署后样式全丢 | CSS 文件路径问题 | 检查 public 静态资源路径,确认独立部署模式配置 |
| OAuth 无限重定向 | 回调地址错误 | 在 Provider 控制台核对回调 URL,补齐路径 |
| 发版后老数据消失 | 迁移脚本没跑 | 执行pnpm prisma migrate deploy |
7.5 我的三个排查习惯
第一,遇到问题先看 terminal 里的完整错误堆栈,不看浏览器控制台的网络报错。同一个错误在两端显示的信息量完全不一样,服务端堆栈通常直接告诉你哪一行炸了。
第二,复杂 bug 我会开一个临时环境,把 Prisma 的日志输出等级调到info,让它打印所有 SQL 语句。这能快速判断数据库层到底发生了什么。生产环境建议关闭日志,避免性能损耗和敏感信息暴露。
第三,凡是牵扯到 session 和 cookie 的问题,永远先看 HTTPS 配置。很多登录失效的根源其实是把secure: true的 cookie 发到了非 HTTPS 环境。
8. 这半年用 t3code 的心得与后续扩展
实际用了大半年 t3code 这套结构做项目,我最深的感受是:这个组合最厉害的地方不是某一个工具,而是"一切都在 TypeScript 的类型系统里流动"。从数据库字段到前端组件 props,类型全程穿透,天然逼着你把数据模型想清楚,把接口契约理干净。做小工具可能体会不到,但项目一旦过万行代码、多人协作,这个收益会越来越明显。
有几个具体的小建议分享给正在上手的朋友:
不要为了用完所有 T3 特性而硬用。默认脚手架会让你选一堆功能,但如果你做个静态展示页根本不需要数据库和 tRPC,那就别加。T3 的魅力在于按需组合,而不是全家桶。
启用<>"、"、'等字符时,我建议在启动项目前把 Prettier 和 ESLint 的配置一次调好,最好加上 Husky 的 pre-commit hook。团队协作时不统一格式,code review(代码评审)会变成一场灾难。
关于后续扩展,我认为一个自然的演进路径是:等业务复杂度上来了,把单一应用拆成 app + packages 的 monorepo。因为 T3 的类型依赖是纯 TypeScript 层面的,抽包共享类型定义很容易。我自己已经把公共类型抽到一个单独的 workspace 包,多个前后端项目直接 import 共享,效果很好。
最后说一说社区生态的可用资源。T3 的核心仓库、相关 template(模板)项目在 GitHub 上都很活跃,遇到问题先搜一下别人的 issue 讨论。与其自己盲试,不如站在别人踩过坑的经验上往前走,这是我在这个项目里学到的最重要的一课。