最近在整理自己的全栈开发工具箱,把好几个项目里反复出现的公共部分抽离出来,做了一个叫 t3code 的项目模板。简单说,t3code 就是基于 T3 Stack 技术栈——Next.js、tRPC、Prisma、Tailwind CSS、NextAuth——的一套完整工程化模板,它把端到端类型安全的全栈开发体验固化成了标准动作,让新项目的初始化时间从折腾大半天压缩到一顿饭的工夫。如果你正在纠结怎么组织一个前后端一体的 TypeScript 项目,或者想尝试 tRPC 这种"没有 API 文档"的开发模式,这篇文章会把这些年的实操经验都摊开讲。
市面上关于 T3 Stack 的教程不少,但大多数停留在"跟着向导点几下"的层面,一旦遇到类型报错、鉴权失效、数据库连接不稳定这类真实问题,资料就变得很零散。我写 t3code 的初衷,就是想把这些坑提前填平,顺便给后来者一份可以照着抄的作业。下面会把技术选型的思考、核心代码的实现逻辑、完整搭建流程以及我实际踩过的坑都过一遍,希望你看完能直接上手,而不是又收藏一个吃灰的链接。
1. 先想清楚 t3code 到底解决什么问题
1.1 做模板之前,我先问了自己三个问题
动手写 t3code 之前,我认真列过一件事:一个全栈项目模板,到底该解决什么?我的答案有三个。
第一,新项目的冷启动成本。每次开新项目都要重新配置 Next.js、接数据库、搭鉴权、配样式,这些重复劳动既没有技术含量又容易出错。一个可靠的模板应该把"地基"打完,让业务代码直接在上面长出来。
第二,团队协作的认知一致性。前端、后端、数据库如果各写各的,接口文档一散,联调就变成扯皮现场。如果一套技术栈能把类型从数据库一路推导到 UI 层,很多沟通成本直接省掉,bug 也能在编译期就暴露。
第三,代码质量的底线保障。模板不仅仅是"能跑",还应该在目录结构、错误处理、鉴权流程、数据库访问这些方面给出标准范式。别人看你的项目代码,一眼就知道某些东西该放哪、该怎么写。
这三个问题看起来简单,但真正把它们落到代码层面,牵扯到的细节远比想象中多。t3code 的每一层配置,几乎都是为了回答其中一个问题而存在的。
1.2 类型安全不是锦上添花,而是省钱机器
T3 Stack 的核心口号是"类型安全优先",初听像一句漂亮话,但用久了会发现它真的在省钱。举一个最直接的例子:传统前后端分离项目里,后端改了个字段名,前端可能到联调阶段才通过 500 状态码发现;而在 t3code 这种 tRPC 模式里,后端改了类型,前端编译直接报错,你根本来不及把 bug 带到浏览器里。
我算过一笔账:一个中等复杂度的后台管理系统,因为类型问题导致的联调返工,平均下来每次要耽搁两三个小时。模板里如果能把类型安全的链路打通,一年省下来的时间足够再做一个小项目。这也是 t3code 把所有组件都收拢在 TypeScript 生态里的根本原因——类型就是契约,契约在编译期就已经对齐了。
2. 技术选型与整体架构思路
2.1 四个核心组件为什么必须凑在一起
很多人第一次看 T3 Stack 会疑惑:Next.js 我能理解,但 tRPC 不是和 REST、GraphQL 重复了吗?Prisma 不就是个 ORM 吗?为什么要塞进同一个项目?
其实这四个组件各自解决的是全栈链路上的不同环节,凑齐了才闭环。Next.js 负责应用框架和渲染策略,页面、路由、服务端函数都由它管理;tRPC 干掉了 API 层的胶水代码,让服务端函数被客户端"直接调用",同时全程类型推导;Prisma 把数据库表映射成类型安全的模型,查询结果天然携带类型;Tailwind 负责 UI 层的快速构建,配合自动补全能省掉大量切换上下文的时间。
更关键的是,这四个组件有一个共同的特性:都优先支持 TypeScript。它们的类型定义不是"事后补的",而是底层就用 TS 写的。这意味着类型信息可以在数据库、服务端、客户端三层之间无损流动,而不是像很多旧方案那样,类型在某层被彻底抹掉,后面全靠手写声明凑合。
2.2 为什么 NextAuth 也值得放进默认配置
鉴权是几乎所有真实项目的刚需,但很多人会把它留到"后面再说"。我在 t3code 里一开始也犹豫过要不要集成 NextAuth,毕竟多一个依赖组件就多一分体积和复杂度。后来实际项目里被鉴权反复折磨了几次,就下定决心必须默认带上。
原因很简单:前期图省事不做鉴权,后期补鉴权的成本高得惊人。你需要改接口、改前端路由守卫、改数据查询范围,一堆页面全部要动。而 NextAuth 作为 Next.js 官方推荐方案,和 App Router 的中间件机制配合得非常好,配置好之后给页面加个登录保护只需要几行代码。t3code 里默认配好了 session 策略、数据库 session 存储和中间件保护路由,你拿来就能用。
我认为最实用的架构思路是"默认完整,按需裁剪":模板里把常用的东西都给齐,你的项目如果确实用不到某些部分,删掉对应的依赖和目录即可。这比从一个空项目开始往上垒要快得多,也安全得多。
3. 核心实现细节与关键代码
3.1 项目初始化:create-t3-app 的取舍
t3code 本身是基于 create-t3-app 二次改造的。官方脚手架已经解决了很多基础问题,但它生成的项目比较"素",缺少我自己长期积累的目录约定、通用工具函数和错误处理范式。所以我保留了它的初始化方式,然后在此基础上做了大量定制。
初始化命令很简单,但需要注意几个选项:
npm create t3-app@latest my-app创建过程中会让你选择要包含的功能模块,我强烈建议勾选 Next.js、tRPC、Prisma、Tailwind、NextAuth,后面两个(Drizzle 和 Expo)按需选择。Drizzle 是另一个 ORM,和 Prisma 二选一即可,一般项目用 Prisma 更省心;Expo 是移动端选项,纯 Web 项目完全可以不选。
创建完成后,官方结构已经比较清晰,但 t3code 在此基础上还会增加几个约定:/server目录下按功能模块划分 domain 文件夹,每个文件夹内部包含 schema、service、router 三件事;/lib里放通用的工具函数;/components/ui放基础组件。这样项目一长大,也不会变成一堆文件的乱葬岗。
3.2 tRPC 端到端类型安全:从 router 到 procedure
tRPC 的使用体验很独特,它不发明新的协议,也不生成额外的运行时代码,纯粹靠 TypeScript 的类型推导把客户端和服务端"缝"起来。t3code 里的 tRPC 结构分三层:
server 端先定义一个根 router,再往里面挂各个子模块的 router;每个子 router 里定义若干 procedure,一个 procedure 就是一个可被客户端调用的完整接口。
// server/api/routers/todo.ts import { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "~/server/api/trpc"; export const todoRouter = createTRPCRouter({ getAll: protectedProcedure .query(async ({ ctx }) => { return ctx.db.todo.findMany({ where: { userId: ctx.session.user.id }, orderBy: { createdAt: "desc" }, }); }), create: protectedProcedure .input(z.object({ title: z.string().min(1).max(100) })) .mutation(async ({ ctx, input }) => { return ctx.db.todo.create({ data: { title: input.title, userId: ctx.session.user.id, }, }); }), toggle: protectedProcedure .input(z.object({ id: z.string(), done: z.boolean() })) .mutation(async ({ ctx, input }) => { return ctx.db.todo.update({ where: { id: input.id }, data: { done: input.done }, }); }), });这里有几个关键点值得展开。procedure 分为publicProcedure和protectedProcedure,区别在于后者强制校验 session,没有登录的用户直接拒绝调用。input用 zod 做运行时校验,类型和校验规则写在同一处,杜绝"数据层说合法、业务层说不合法"的拧巴。
客户端调用的时候,类型是自动推导的。前端写api.todo.getAll.useQuery(),你就能拿到类型完整的返回结果,字段拼错直接红线。这种体验用久了真的回不去——你不再需要去看接口文档,代码本身就是文档。
3.3 Prisma 数据层:schema 设计与迁移
Prisma 的 schema 文件是整个数据层的源头。t3code 里我习惯把用户相关的模型、业务模型分文件管理,然后通过prismaSchemaFolder特性让 Prisma 读取整个文件夹。下面是一段典型的 schema:
generator client { provider = "prisma-client-js" } datasource db { provider = "sqlite" url = env("DATABASE_URL") } model User { id String @id @default(cuid()) name String? email String @unique emailVerified DateTime? image String? accounts Account[] sessions Session[] todos Todo[] } model Todo { id String @id @default(cuid()) title String done Boolean @default(false) createdAt DateTime @default(now()) userId String user User @relation(fields: [userId], references: [id]) }开发环境默认用 SQLite,零配置就能跑起来,这对本地调试非常友好。生产环境我一般会换成 PostgreSQL,只需要改 datasource 里的 provider 和 DATABASE_URL 环境变量,业务代码一行都不用动。
注意一个常见的坑:改了 schema 后一定要执行迁移,而且生产环境不要直接跑prisma db push,那是开发环境偷懒用的。正确的流程是:
# 开发环境 npx prisma migrate dev --name init # 生产环境 npx prisma migrate deploymigrate dev会生成 SQL 迁移文件并同步数据库,migrate deploy会把已有的迁移文件应用到生产库。两套动作的授权模型完全不同,搞混了轻则报错,重则把生产数据弄丢。
3.4 NextAuth 集成:Session 与受保护路由
NextAuth 在 t3code 里承担两个职责:一是管理登录态,二是配合中间件保护路由。配置里有三个地方要重点关注。
第一个是主配置,写在server/auth.ts里:
import NextAuth from "next-auth"; import GitHub from "next-auth/providers/github"; import { PrismaAdapter } from "@next-auth/prisma-adapter"; import { db } from "~/server/db"; export const { handlers, auth, signIn, signOut } = NextAuth({ adapter: PrismaAdapter(db), providers: [GitHub], session: { strategy: "database" }, callbacks: { session: ({ session, user }) => ({ ...session, user: { ...session.user, id: user.id }, }), }, });provider 我这里以 GitHub 为例,实际项目根据需求换成邮箱、微信、手机号都行。strategy: "database"会把 session 存进数据库,配合 Prisma 的 Account/Session 模型,服务端才能在 tRPC 的 protectedProcedure 里读到当前登录用户。
第二个是中间件保护路由,写在根目录的middleware.ts:
export { auth as middleware } from "@/server/auth"; export const config = { matcher: ["/dashboard/:path*", "/admin/:path*"], };这个文件的作用非常直白:匹配到的路径,未登录用户一律跳转登录页。注意要在自己的项目里实际确认一下中间件和路由前缀的匹配关系,很多人配置了 matcher 却发现在某些嵌套路由上失效,多半是路径写得不精确。
第三处是 tRPC 上下文里的 session 注入。在server/api/trpc.ts里,把 auth 返回的 session 放进 ctx,后续所有 procedure 就能直接使用ctx.session。这样鉴权信息就能从 NextAuth 一路流到数据库查询条件里,比如查"当前用户的待办"就非常自然。
3.5 Tailwind 样式体系与 UI 组织
样式部分 t3code 沿用 Tailwind,但我额外定了一套小组件规范。基础按钮、输入框、卡片全部拆到components/ui,业务组件不允许直接裸写按钮样式,必须复用基础组件。这套规范一开始觉得啰嗦,项目大了以后收益非常明显——改一个按钮圆角,全站只要动一处,而不是全局搜索七八种不同的 className。
Tailwind 配置里我还加了cn工具函数(基于 clsx 和 tailwind-merge),用来处理条件样式拼接:
import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }这玩意解决了一个经典痛点:多个 className 互相覆盖。比如默认样式是"px-4 py-2",某个特殊场景要改成"p-3",直接用字符串拼接会同时存在px-4和p-3,而tailwind-merge能识别出 p-3 是后面的,自动把冲突的 px-4 py-2 去掉。这种细节看着小,实际用起来特别提升开发幸福感。
4. 完整实操流程:从零到上线
4.1 环境准备与版本要求
动手之前先确认环境,这一步能省掉后续一半的报错。我建议的版本组合是 Node.js 20 以上、npm 10 以上(用 pnpm 也行,但别在同一个项目里混用包管理器)。Prisma 对 Node 版本有要求,太老的版本装依赖容易失败;Next.js 14/15 对 React 版本也有要求,用官方 create-t3-app 生成的版本组合一般是最稳的。
另外提前准备好一个空的 GitHub OAuth App,因为 NextAuth 的 GitHub provider 需要客户端 ID 和密钥。这个步骤很多人卡住,注意回调地址在本地开发是http://localhost:3000/api/auth/callback/github,上线后要换成真实域名。
4.2 创建项目与依赖安装
执行初始化命令,然后安装依赖:
npm create t3-app@latest t3code-demo cd t3code-demo npm install初始化向导会问你要用哪几个模块,按前面说的勾选即可。装完后复制.env.example为.env,把数据库连接串、GitHub OAuth 的 ID 和密钥填进去。
这里有一个我在很多项目里见到过的低级错误:把.env文件提交到 Git。t3code 的模板里已经默认把.env写进.gitignore,但如果你是自己改出来的项目,务必检查一下。密钥泄露不是闹着玩的,我见过不止一次因为 .env 被提交到公开仓库,导致花了大半天轮换所有密钥。
4.3 用 t3code 实现一个完整的待办功能模块
做完初始化,下一步就是把 t3code 跑起来做点实际的事。我以一个最简单的待办清单为例,演示完整的增删改查链路。
第一步:设计数据模型。打开prisma/schema.prisma,添加 Todo 模型,关联到 User,然后执行npx prisma migrate dev --name add_todo。
第二步:创建 tRPC 路由。新建server/api/routers/todo.ts,参考前面贴的代码,写上 getAll、create、toggle、delete 四个 procedure。注意所有涉及用户数据的操作都要用protectedProcedure,并且查询条件强制带userId,防止越权读取别人数据。
第三步:把路由挂到根 router。打开server/api/root.ts,把 todoRouter 挂进去。这一步很多人会忘记,导致客户端一直抱 404。
第四步:写前端页面。新建app/todos/page.tsx,用useQuery和useMutation调用接口。tRPC 客户端已经自动生成好了,直接 import 即可。
"use client"; import { api } from "~/trpc/react"; export default function TodosPage() { const utils = api.useUtils(); const { data: todos, isLoading } = api.todo.getAll.useQuery(); const createTodo = api.todo.create.useMutation({ onSuccess: () => utils.todo.getAll.invalidate(), }); const toggleTodo = api.todo.toggle.useMutation({ onSuccess: () => utils.todo.getAll.invalidate(), }); if (isLoading) return <div>加载中...</div>; return ( <div> <form onSubmit={(e) => { e.preventDefault(); const form = e.currentTarget; const input = form.elements.namedItem("title") as HTMLInputElement; createTodo.mutate({ title: input.value }); form.reset(); }} > <input name="title" placeholder="新待办" /> <button type="submit">添加</button> </form> <ul> {todos?.map((todo) => ( <li key={todo.id}> <input type="checkbox" checked={todo.done} onChange={() => toggleTodo.mutate({ id: todo.id, done: !todo.done })} /> {todo.title} </li> ))} </ul> </div> ); }这段代码的核心技巧是onSuccess: () => utils.todo.getAll.invalidate()。mutation 成功后,把缓存里的 getAll 数据标为失效,react-query 会自动重新拉取最新列表。不需要手动改本地状态,也不需要刷新页面,省掉了一堆样板代码。
第五步:验证类型安全。试试故意把todo.title写成todo.titl,编辑器应该立刻飙红色。这个反馈链路是整个 t3code 最值钱的地方。如果没报错,检查一下 tRPC 客户端是否真的从同一个 server 类型推导,很多人"伪类型安全"就是因为在客户端手写了类型声明,把这条链路破坏了。
4.4 部署阶段的关键调整
开发完后部署也不能马虎。如果部署到 Vercel,有几个点需要注意:环境变量要重新填一遍,数据库要用生产环境的连接串;NextAuth 的回调地址改成正式域名;Prisma 在构建时需要执行prisma generate,很多部署失败都是因为这一步缺失。
我的部署流程是先在本地跑一次生产构建npm run build,确认没有类型错误,再推送到部署平台。因为 t3code 是全类型安全的项目,类型错误会在构建期暴露,这反而是好事——总比用户点开页面看到白屏强。
5. 常见问题与排查实录
5.1 tRPC 类型不同步,怎么办
tRPC 的类型推导依赖服务端 router 的类型,如果你改了 server 端代码,客户端没反应,最常见的原因是 dev server 没有重新加载。tRPC 的类型是编译期推导的,理论上和热更新无关,但实际使用中我自己遇到过几次:t3code 里额外引入的类型入口文件(比如server/api/root.ts)被缓存了,重启 dev server 就恢复正常。
如果重启还不行,那就检查是不是有人手动在客户端写了type Todo = {...}之类的本地类型。一旦有手写类型介入,整个推导链就被截断了。记住一句话:t3code 的原则是"类型只能有一个来源",让 TypeScript 自己去找答案,别帮它做决定。
5.2 Prisma 连不上数据库
Prisma 连接数据库报错的概率非常高,但原因往往就那么几种。先确认 DATABASE_URL 格式对不对,PostgreSQL 的格式是postgresql://USER:PASSWORD@HOST:PORT/DATABASE,密码里如果包含特殊字符要 URL 编码,比如@要写成%40。
本地用 SQLite 的时候,记得环境变量是file:./db.sqlite,别手滑写成localhost:3306之类的。再一个高频错误是生产环境没有执行迁移,表根本不存在,migrate deploy一下就好。
5.3 NextAuth 登录跳转异常、Session 一直拿不到
NextAuth 出问题,九成和 session 取法有关。在 t3code 里,服务端代码拿 session 要用auth()或者 tRPC 的 ctx.session,客户端拿 session 要用useSession()。如果你在客户端组件里直接调用服务端的 auth(),那拿到的永远是 null。
还有一个非常隐蔽的坑:NextAuth 的secret没有配置时,开发模式还能跑,生产环境直接报错。t3code 模板里已经把这个变量预留了,填一下.env里的NEXTAUTH_SECRET即可,用openssl rand -base64 32生成一个强随机串。
5.4 常见问题速查表
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 客户端调用 tRPC 报 404 | 路由没挂到 root router | 检查server/api/root.ts |
| 类型不更新 | dev server 缓存 | 重启 dev server,取消手写类型 |
| Prisma 查询报表不存在 | 没执行迁移 | npx prisma migrate deploy |
| 登录后 session 为空 | 客户端用了服务端方法 | 改用useSession() |
| 构建时 prisma 报错 | 未执行 generate | 构建前加prisma generate |
| Tailwind 样式覆盖失效 | className 冲突 | 用cn()合并 |
6. 一些实际操作中的体会
t3code 从成型到现在,我前后用它开了七八个项目,最直观的感受是"焦虑变少了"。以前开新项目,光是把数据库、鉴权、接口层接起来就要心虚几天,现在模板一套、改改业务代码,当天就能让用户看到能点能跑的东西。这种正向反馈对保持开发热情很重要。
最后再分享一个小心得:模板不是越全越好。t3code 默认带的东西我已经反复裁剪过,但每个人的项目口味不同,你可以大胆删掉自己用不到的部分。比如纯前端项目就用不着 Prisma,直接去掉相关的依赖和目录,别让用不到的东西增加认知负担。最好的模板是你敢动手改它,而不是拿它当圣旨供着。
如果你准备在自己的下一个项目里试试 t3code,我建议从一个小而完整的业务模块开始,比如我文章里写的待办功能。跑通一条增删改查的链路,你就能体会到服务端和客户端共享类型的爽感。剩下的,就交给时间去验证吧。