前两天把一个攒了很久的 side project 用 t3code 重新搭了一版,整个过程比我预想中顺不少。顺手把 t3code 的使用心得和实现细节整理成文,给打算从零开始做全栈应用、又不想在脚手架配置上浪费太多时间的同学做个参考。
t3code 是一个基于 T3 技术栈的全栈应用脚手架,它把 Next.js、TypeScript、Tailwind CSS、Prisma 和 tRPC 打包成一套开箱即用的项目模板。只要执行一条初始化命令,就能得到一个前后端完全打通、并且从数据库到组件都共享同一套类型定义的应用骨架。t3code 解决的核心问题,是“全栈类型安全”和“项目启动速度”这两件事。适合已经会写 React、但对服务端和数据库配置还不够熟的开发者,也适合想快速验证产品想法、又不想被一堆基建问题拖住的人。
这篇文章我会从为什么需要 t3code、怎么上手、核心架构、具体做一个带登录的 Todo 功能,再到常见的坑和部署,一条线讲完。涉及的命令和代码我都直接贴出来,你可以跟着操作。
1. 项目定位:为什么会有 t3code,以及它到底帮你省了哪些事
1.1 T3 技术栈的核心理念
T3 并不是某个单一框架的名字,而是一组“类型安全优先、务实渐进”的技术组合。通常指的是 Next.js + TypeScript + Tailwind CSS + tRPC,再搭配 Prisma 和 NextAuth.js。这个组合的核心主张是:所有端到端的数据交互都通过类型系统保证,而不是靠运行时猜测。
“类型安全优先”听起来像口号,我举一个实际例子。以前写前后端联调,你需要自己在前端定义一个 interface 去“冒充”后端返回的数据结构。一旦后端接口改了字段名,前端编译期不会报错,只有页面运行时才会出现undefined或者白屏。而在 t3code 生成的工程里,从数据库表定义开始就自动生成类型,所有链路共享同一份类型推导。说人话:数据库里有一个title字段,你在前端组件里误写成todo.titile,编译时直接报错,根本等不到运行时。
所以 T3 技术栈的本质,是把“类型安全”当作一项基础设施来对待。t3code 不是发明了新技术,而是把这套已经被验证过的组件组合起来,做成了可复用的模板。你不需要每次都从零配置,只要理解每个模块的位置和作用,就能快速上手。
1.2 t3code 解决的三个痛点
第一,选型困难症。我们团队以前新起项目,光技术讨论就要半天:用 Pages Router 还是 App Router,用 Redux 还是 React Query,用 axios 还是 fetch,用 TypeORM 还是 Prisma。t3code 直接给出了一套经过验证的默认组合。如果某些模块用不上,初始化时也可以关掉,而不是被迫接受一个“全家桶”。
第二,配置地狱。NextAuth 要配回调地址,Prisma 要配数据库连接,Tailwind 要配 content 路径,这些零碎配置第一次做很容易漏。t3code 把这些预置好,并且保证它们能协同工作。比如 Prisma 的db.ts里会做全局单例,避免开发环境热更新时创建太多数据库连接;tRPC 的 context 会自动注入 session,省去自己写接口鉴权的重复劳动。
第三,前后端类型不同步。只要经历过一次后端字段改名导致前端静默出错的痛苦,你就能理解 tRPC 的价值。t3code 把 tRPC 集成到 Next.js 的 App Router 里,前端调用.useQuery()时,返回数据的类型是自动推导出来的,不需要手写第二个 interface。改后端模型时,前端所有引用点都会被 TypeScript 编译器标红,你可以一次性改完,而不是上线后靠用户反馈来发现问题。
2. 快速上手:5 分钟跑通一个 t3code 初始项目
2.1 环境准备
开始之前,需要确认本机环境满足下面这几点:
- Node.js 版本 18.17 以上。t3code 底层依赖 Next.js 14 和 TypeScript 5,太旧的 Node 跑不起来。
- 推荐使用 pnpm 作为包管理器。pnpm 的依赖管理更严格,t3code 内部的一些 workspace 配置默认按 pnpm 来。没有装的话先用
npm install -g pnpm装一个。 - Git 已经安装,后续初始化流程会用到。
用终端检查一下:
node -v pnpm -v如果 Node 版本太低,建议先装一个 nvm,方便切换版本。t3code 本身不强制要求特定数据库,本地开发用的是 SQLite,所以不需要额外安装数据库服务,这对快速体验非常友好。
2.2 初始化命令与交互选项
在终端执行:
npx create-t3code@latest my-app或者使用 pnpm:
pnpm create t3code@latest my-app执行后会进入交互式问答。第一次跑的时候可能会被大量选项搞懵,我把每一项拆开来说:
- Next.js App Router / Pages Router:建议选 App Router。它是 Next.js 新版本的主推,支持 Server Components,配合 tRPC 时 t3code 已经帮你处理好了边界。
- Prisma:默认开启。它让你的数据库建模和查询都获得类型安全。本地默认使用 SQLite,几乎零成本。
- NextAuth.js:认证库。你的应用如果需要用户登录,就保留它。它会自动生成一个包含
getServerSession的 auth 封装。 - tRPC:建议保留。这是 t3code 的灵魂所在,少了它,前后端类型共享就无从谈起。
- Tailwind CSS:样式方案。如果你习惯用普通 CSS 也可以关闭,但默认带着能省很多事。
- ESLint / Prettier:默认开启,没什么坏处。
我的建议是:新手第一次全选,先拿到一套完整可运行的项目,再根据需求裁剪。我自己的项目全选了,后面并没有觉得冗余,反而每个模块都用上了。
2.3 首次启动与验证
初始化完成后,进入项目目录:
cd my-app pnpm install npx prisma db push npx prisma generate pnpm devprisma db push会把schema.prisma里定义的模型同步到 SQLite 数据库文件,prisma generate会生成 Prisma Client 类型。之后打开http://localhost:3000,你应该能看到一个带登录链接的首页。
如果之前没有配置任何 OAuth,NextAuth 默认提供邮箱登录。开发模式下,它会把登录链接直接打印在终端里,点开即登录,非常方便。此时就可以登录,然后访问页面里受保护的示例接口,比如/api/trpc/post.all。我第一次跑通时最直观的体验是:前端调用 query 时,返回数据的所有字段都有智能提示,根本不需要查接口文档。
3. 核心架构:端到端类型安全是怎么实现的
3.1 数据流全景
t3code 之所以能做到端到端类型安全,关键在一条完整的数据链路,它的走向是这样的:
浏览器里的 React 组件 → 调用 tRPC 的 React Query Hooks(如api.todo.getAll.useQuery()) → 通过网络请求打到 Next.js 的 Route Handler/api/trpc/*→ tRPC 将请求路由到对应的 Procedure → Procedure 内部直接调用 Prisma Client 操作数据库 → 返回数据。
整个过程不需要手写 REST 接口、不需要手写数据校验层、不需要手写枚举类型。tRPC 会自动推断 Procedure 的输入输出类型,Prisma 会根据模型生成数据库查询的类型,React 组件里的data字段就是数据库查询返回结果的精确类型。有一处类型错误,编译器会立刻指出来,而不是等接口跑起来才发现。
3.2 目录结构与关键文件拆解
初始化之后的目录结构大致是这样(我删掉了一些无关文件):
prisma/ schema.prisma src/ app/ api/trpc/[trpc]/route.ts layout.tsx page.tsx _components/ server/ api/ root.ts routers/ post.ts auth.ts db.ts trpc/ react.tsx server.ts逐个说重点:
prisma/schema.prisma:你的数据模型定义文件。所有业务表结构都写在这里。src/server/db.ts:创建 Prisma Client 实例,并做全局缓存。开发模式下 Next.js 的 HMR 会频繁刷新模块,如果不做单例,可能会导致数据库连接数暴涨。src/server/auth.ts:NextAuth 的配置出口,导出一个getServerAuthSession函数,用于在服务端获取当前登录状态,同时也能注入到 tRPC 的 context 中。src/server/api/root.ts:tRPC 的根路由文件,里面把各个子 Router 合并成一个appRouter。src/server/api/routers/:具体的业务 Router,比如示例的post.ts。后续我们新增的 Todo 功能也会放这里。src/trpc/react.tsx:前端 tRPC 客户端,导出一个api对象,包含所有 React Query Hooks,以及api.useUtils()等操作缓存的方法。src/trpc/server.ts:服务端调用 tRPC 的 Helper,适合在 Server Component 里直接查询数据。src/app/api/trpc/[trpc]/route.ts:所有 tRPC 请求的统一入口。Node.js 环境下的 HTTP 请求都会走这个 handler。
3.3 tRPC 的两个核心概念:Procedure 与 Middleware
在 t3code 里,tRPC 被封装得比较薄,核心概念需要你搞明白。
Procedure 是 tRPC 的基本操作单元。你可以理解为“一个后端函数”。它有query、mutation、subscription三种类型:query用于取数据,mutation用于写操作,subscription用于实时数据。Router 就是一组 Procedure 的集合。
Middleware 则是在 Procedure 执行前后可以挂载的逻辑。t3code 在src/server/api/trpc.ts里写了一个protectedProcedure,它的作用就是校验用户是否已登录:
export const protectedProcedure = t.procedure.use(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { session: { ...ctx.session, user: ctx.session.user }, }, }); });每个需要登录才能访问的接口,统一使用protectedProcedure而不是createTRPCProcedure,从源头杜绝“忘了加鉴权”的问题。我第一次写后端接口时经常忘,后来强制自己只用protectedProcedure,一个月下来就形成了习惯。
4. 实操:用 t3code 快速实现一个带登录的 Todo 应用
4.1 设计数据模型
在prisma/schema.prisma里新增一个 Todo 模型:
model Todo { id String @id @default(cuid()) content String done Boolean @default(false) userId String user User @relation(fields: [userId], references: [id]) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }这里User模型是 NextAuth 自动生成的,我们只需要在 User 模型上加一个反向关联字段:
model User { // 原有的 accounts, sessions 字段保持不变 todos Todo[] }保存后运行:
npx prisma db push npx prisma generate如果以后要改动模型并保留数据,就需要用npx prisma migrate dev来生成迁移记录。db push更适合开发初期表结构还不稳定的阶段。
4.2 编写 Todo Router
在src/server/api/routers/下新建todo.ts:
import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { TRPCError } from "@trpc/server"; export const todoRouter = createTRPCRouter({ getAll: protectedProcedure.query(({ ctx }) => { return ctx.db.todo.findMany({ where: { userId: ctx.session.user.id }, orderBy: { createdAt: "desc" }, }); }), create: protectedProcedure .input(z.object({ content: z.string().min(1).max(500) })) .mutation(({ ctx, input }) => { return ctx.db.todo.create({ data: { content: input.content, userId: ctx.session.user.id, }, }); }), toggle: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { const todo = await ctx.db.todo.findUnique({ where: { id: input.id }, }); if (!todo || todo.userId !== ctx.session.user.id) { throw new TRPCError({ code: "NOT_FOUND" }); } return ctx.db.todo.update({ where: { id: input.id }, data: { done: !todo.done }, }); }), delete: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { const todo = await ctx.db.todo.findUnique({ where: { id: input.id }, }); if (!todo || todo.userId !== ctx.session.user.id) { throw new TRPCError({ code: "NOT_FOUND" }); } return ctx.db.todo.delete({ where: { id: input.id } }); }), });然后在src/server/api/root.ts里把它加进去:
import { todoRouter } from "./routers/todo"; export const appRouter = createTRPCRouter({ post: postRouter, todo: todoRouter, });这里有一个非常容易忽略的重点:在toggle和delete里,我不仅检查了 Todo 是否存在,还检查了todo.userId是否等于当前登录用户的 id。否则一个登录用户可以任意删除别人的 Todo,这是很常见的安全漏洞。t3code 不会自动帮你做对象级权限判断,必须自己在逻辑里补上。
4.3 前端组件接入
新建src/app/_components/TodoList.tsx:
"use client"; import { useState } from "react"; import { api } from "@/trpc/react"; export function TodoList() { const utils = api.useUtils(); const [content, setContent] = useState(""); const { data: todos, isLoading } = api.todo.getAll.useQuery(); const createMutation = api.todo.create.useMutation({ onSuccess: () => { void utils.todo.getAll.invalidate(); }, }); const toggleMutation = api.todo.toggle.useMutation({ onSuccess: () => { void utils.todo.getAll.invalidate(); }, }); const deleteMutation = api.todo.delete.useMutation({ onSuccess: () => { void utils.todo.getAll.invalidate(); }, }); if (isLoading) return <div>加载中...</div>; return ( <div className="max-w-md mx-auto p-4"> <form onSubmit={(e) => { e.preventDefault(); if (!content.trim()) return; createMutation.mutate({ content }); setContent(""); }} className="flex gap-2 mb-4" > <input value={content} onChange={(e) => setContent(e.target.value)} className="flex-1 border rounded px-2 py-1" placeholder="新 Todo" /> <button type="submit" className="bg-blue-500 text-white rounded px-3 py-1"> 添加 </button> </form> <ul className="space-y-2"> {todos?.map((todo) => ( <li key={todo.id} className="flex gap-2 items-center"> <input type="checkbox" checked={todo.done} onChange={() => toggleMutation.mutate({ id: todo.id })} /> <span className={todo.done ? "line-through" : ""}>{todo.content}</span> <button className="ml-auto text-red-500" onClick={() => deleteMutation.mutate({ id: todo.id })} > 删除 </button> </li> ))} </ul> </div> ); }这里最能体现 t3code 的好处:api.todo.create.useMutation()这种写法,完全不需要自己维护缓存状态。每次 mutation 成功后,我们调用utils.todo.getAll.invalidate()让列表重新请求一次,React Query 会自动更新页面。整个过程中,没有任何手工拼接 URL、序列化 JSON 的痕迹。
4.4 在页面中挂载并控制登录态
在src/app/page.tsx中,我们可以在服务端判断登录状态,只有登录用户才能看到 TodoList:
import { auth } from "@/server/auth"; import { TodoList } from "./_components/TodoList"; export default async function HomePage() { const session = await auth(); if (!session?.user) { return <div className="text-center mt-10">请先登录</div>; } return <TodoList />; }因为TodoList用到了useState和 tRPC hooks,必须把它拆成"use client"组件,而page.tsx保持 Server Component。这个边界在 t3code 里已经提前划好,你只需要遵循模式即可。此时启动项目,登录后就能正常操作 Todo 了。
5. 常见问题速查与排查实录
5.1 Prisma Client 类型报错
症状:启动项目时 TS 报错Cannot find module '@prisma/client',或者Property 'todo' does not exist on type 'PrismaClient'。
原因:改了schema.prisma之后没有运行prisma generate。Prisma Client 不是普通 npm 包,它的类型和查询 API 是根据 schema 动态生成的。如果不重新生成,代码里引用新模型就会报错。
解决:每次修改 schema 后执行npx prisma generate。更稳妥的方式是在package.json的postinstall脚本里加上prisma generate,这样每次安装依赖后都会自动生成:
{ "scripts": { "postinstall": "prisma generate" } }5.2 tRPC Hooks 找不到 api 对象
症状:在客户端组件里使用api.todo.getAll.useQuery()时,编辑器提示api为 undefined,或者直接报Cannot find name 'api'。
原因:没有从正确路径导入api,或者组件没有被<TRPCReactProvider>包裹。t3code 中api定义在src/trpc/react.tsx,并且layout.tsx里已经挂载了 Provider。如果你新增了一个页面但没有经过根 layout,Provider 就不会生效。
解决:检查src/app/layout.tsx是否包含<TRPCReactProvider>{children}</TRPCReactProvider>。同时,所有使用 hooks 的组件顶部必须有"use client"指令,否则会在 Server 端执行出错。
5.3 NextAuth 环境变量和回调地址
症状:点击登录后跳转出现[next-auth][error][MISSING_SECRET],或者第三方登录一直转圈。
解决:在.env中设置NEXTAUTH_SECRET。可以用系统自带命令生成:
openssl rand -base64 32如果使用 GitHub OAuth,回调地址必须填http://localhost:3000/api/auth/callback/github。部署到生产环境后,记得把应用域名填进去,并重新生成安全的 secret。这个坑我踩过两次,每次都是因为没仔细看 NextAuth 官方文档里“回调 URL 要和 baseUrl 保持一致”的提示。
5.4 SQLite 数据库锁问题
症状:本地开发时偶尔出现database is locked错误。
原因:SQLite 不像 PostgreSQL、MySQL 那样适合高并发写入。多个进程同时写同一个dev.db文件,或者一个连接长时间占用事务,就可能锁库。
解决:开发阶段尽量保持只有一个pnpm dev进程在跑,不要同时启动 dev server 和测试脚本。如果项目进入生产阶段,建议把DATABASE_URL换成 PostgreSQL。t3code 的 Prisma schema 不用改,只需要重跑prisma db push或migrate即可。
6. 部署与后续扩展
6.1 部署到 Vercel 的关键配置
t3code 生成的 Next.js 项目非常适合部署到 Vercel,但有几个点需要注意:
- 生产环境变量要全部配置在 Vercel 的项目设置里。包括
DATABASE_URL、NEXTAUTH_SECRET以及 OAuth 相关的 key。 - 构建指令建议设置为
prisma generate && next build。因为 Vercel 的构建环境是全新的,必须重新生成 Prisma Client。 - 如果你的后端 API 跑在 Serverless 函数里,用 SQLite 会导致数据丢失。因为 Serverless 的函数实例是无状态的,文件系统不持久化。必须使用托管数据库,比如 PostgreSQL。
- 使用 Prisma + Serverless 时,注意数据库连接数限制。最简单的优化是开启 Prisma Accelerate 连接池,或者在连接串上启用 PgBouncer 兼容模式。
6.2 从 t3code 项目继续延伸
当你的 Todo 应用跑通后,可以顺着 t3code 的架构继续加功能。比如:
- 给 Router 增加分页参数,只需要在 input 里加一个
page字段,前端立刻获得类型提示。 - 接入文件上传,上传后的 URL 可以当成普通字符串存在 Prisma 模型里。
- 把某些读接口改成
procedure后部署到 Edge Runtime,享受更低的访问延迟。 - 如果项目变成 monorepo,可以把 tRPC Router 拆成单独的 package,让多个前端项目共用同一套类型。
我个人在使用 t3code 半年后最大的体会是:它并没有要求你学习什么新的哲学,而是把早已成熟的技术组合,用一套合理的默认配置串了起来。你不需要再为“该不该用 GraphQL”“要不要自己封装 fetch”之类的问题纠结。先跑起来,再在架构上做局部调整,这才是 t3code 最舒服的使用节奏。
最后再分享一个小技巧:如果你想把某个已有项目也迁移到 t3code 的体系里,可以先用 t3code 初始化一个空白项目,然后把原有组件和业务代码逐步搬进来。迁移过程中,TypeScript 编译器会帮你标出所有类型不匹配的位置。我在一次历史项目重构中就是这样做的,原本计划两周的迁移,实际只用了四天,大部分时间都花在改命名上,而不是排查联调 bug。这种“类型牵引重构”的体验,值得每个被接口文档折磨过的开发者试一次。