最近在 GitHub 上刷到一个叫 t3code 的仓库,打开之后大概率会被它从头到尾的类型安全给震一下。t3code 本质上就是一个用 T3 Stack 搭建的全栈示例项目,T3 Stack 的核心成员是 Next.js、TypeScript、Tailwind CSS 和 tRPC,再配上 Prisma 和 NextAuth 做数据层与鉴权。它想解决的问题很朴素:前后端类型割裂、接口文档经常过期、数据库字段一改页面也跟着炸,这些全栈开发的日常痛苦,能不能在项目一开始就消除掉?
这个项目对三类人特别有价值:刚学完 Next.js、想看一个完整项目长什么样的前端新手;被接口文档和联调折磨了半年的全栈开发者;以及想快速搭内部小工具、又不想引入重型框架的团队。我第一次完整跑通 t3code 的流程用了一个下午,之后每次开新项目都会不自觉地翻回它的目录结构。这篇就当是把我当时看得最认真、也最容易被忽略的几个地方展开聊一聊,顺便把一些埋过坑的经验一并写出来。
1. 项目整体设计与思路拆解
1.1 为什么偏偏是这几个组件凑在一起
T3 Stack 不是某个大厂推出来的官方框架,它是社区里把几个开发理念高度契合的工具打包到一起的产物。最早流行起来的时候,很多人觉得这就是"把 Next.js 全家桶又包了一层",实际用下来你会发现,这几个组件解决的是同一个核心痛点:类型割裂。
说人话就是:传统前后端分离,前端写 fetch 的时候根本不知道后端返回什么结构,后端改字段了前端也不知道,只能靠文档、靠猜、靠联调现场对字段。T3 Stack 的思路是让数据库 schema 直接长出 TypeScript 类型,然后通过 tRPC 让 API 的类型自动流到前端调用处。你改一个 Prisma 字段,保存文件,前端立刻看到类型报错。这种"改错当场发现"的体验,比任何代码审查都高效。
每个成员选进来都有明确理由:
- Next.js 管页面、路由、服务端渲染和打包,是整个应用的骨架;
- TypeScript 提供静态类型,是整条链路的基础设施;
- Tailwind CSS 管样式,原子类写法在组件多的时候特别省心;
- tRPC 代替手写 REST 接口,让写 API 变成把函数暴露出去;
- Prisma 管数据库,schema 即类型,迁移和查询都顺手;
- NextAuth.js 管登录,内置各种社交账号和凭证登录。
一句话总结设计哲学:少造轮子,不强行上重型框架,把类型安全当成骨架来打。
1.2 t3code 的模块到底分了哪几层
看一个 T3 项目,我会习惯从下往上分五层看。第一层是数据层,由 Prisma schema 定义,所有业务表和关系都在这里;第二层是服务层,tRPC 的 router 在这里声明 procedure,具体写数据库查询和业务校验;第三层是协议层,zod 当作输入校验器,同时生成运行时类型;第四层是应用层,Next.js 页面和 Server Component 负责拼装 UI;第五层是样式层,Tailwind 负责把样式原子化。
t3code 的典型目录结构大致是这个样子:
t3code/ ├── prisma/ │ └── schema.prisma ├── src/ │ ├── app/ │ │ ├── api/auth/[...nextauth]/route.ts │ │ ├── layout.tsx │ │ └── page.tsx │ ├── server/ │ │ ├── api/ │ │ │ ├── root.ts │ │ │ ├── routers/ │ │ │ │ ├── post.ts │ │ │ │ └── ... │ │ │ └── trpc.ts │ │ ├── auth.ts │ │ └── db.ts │ ├── trpc/ │ │ ├── react.tsx │ │ └── server.ts │ └── styles/ │ └── globals.css └── .env不要小看这个目录的划分逻辑。server下面的代码只跑在服务端,app下面既可以服务端渲染又能挂客户端组件,trpc目录是桥接层。这种分层在项目变大之后特别有用,你不会在页面里突然写一堆ctx.db的直连查询,所有数据访问都收敛到 router 里,审计和排查都方便。
1.3 这个方案舍弃了什么
任何组合拳都有代价,T3 Stack 也不例外。它默认绑定 Next.js,如果实际业务要用 Vue,或者需要一套完整的微前端架构,这套思路就得打很多补丁。它默认用 Prisma,如果你用惯了 SQL 裸查询、或者打定主意要上 Drizzle 这类更轻的 ORM,这里会有点缚手缚脚。它默认 NextAuth,如果要接企业微信、钉钉这类不太主流的登录方式,NextAuth 的 provider 生态有时候得自己写扩展。
我在实际项目里遇到最多的是登录兼容问题。NextAuth 在低版本和高版本之间的配置方式差别不小,尤其是新版本 Next.js 的 App Router 出来之后,app/api/auth/[...nextauth]/route.ts这个文件的写法很多人会踩坑。这部分后面我会单独讲,因为几乎所有新手的第一个坎都横在这里。
2. 从零搭建:环境准备与目录结构解析
2.1 一条命令启动项目,但这些选项要理解
用 T3 官方脚手架建项目非常简单:
npm create t3-app@latest t3code之后它会问你要不要启用 NextAuth.js、Prisma、Tailwind、tRPC、ESLint、环境变量验证。新手最容易纠结:到底选几个?我的建议是,第一次接触 T3 Stack 就把默认的全部打钩。因为你拆掉任何一块都会破坏教程的连贯性,尤其是看到这里的人多半是想完整了解 T3 的形态。但如果你只想快速做一个不带账号体系的个人 demo,把 NextAuth 关掉也行,后面按最小的 CURD 项目操作就好。
脚手架装完之后,项目里会自动生成一个.env文件,里面有几个必须填的环境变量。这里有一个容易忽略的点:DATABASE_URL如果留空,直接跑npm run db:push会报错,而且报错信息不会很直观。建议拿到项目的第一步就是把数据库链接先配上,别急着改业务代码。
2.2 需要额外安装的工具和前置条件
Node.js 版本建议 18.17 以上,这个卡得很死。因为 Next.js 14 依赖fetch和ReadableStream这些运行时特性,老版本 Node 不是少这就是缺那,装完包一跑就出现奇怪的运行时错误。包管理器用 npm 或 pnpm 都行。T3 模板默认会把packageManager写在 package.json 里,我个人建议用 pnpm,主要是装依赖速度和团队的 Node 版本切换体验都好不少。
数据库方面,本地开发我推荐先用 SQLite 起步,等 schema 稳定了再切 PostgreSQL。不要一上来就上复杂数据库,SQLite 零配置这个特点能让你把注意力集中在业务代码上。要切 PostgreSQL 的时候,只需要把datasource db的provider改成postgresql、更新DATABASE_URL,重新跑一次迁移,绝大多数模型都不需要改动。这个成本比很多新手想象的低得多,所以完全不用担心选错起步库。
2.3 几个核心配置文件的现场解释
进入项目后,我建议先看三个文件。第一个是 package.json 的 scripts,你会发现里面有很多以db:开头的脚本:
{ "scripts": { "db:push": "prisma db push", "db:generate": "prisma generate", "db:studio": "prisma studio", "dev": "next dev" } }第二个是 next.config.mjs,它相对干净。但如果你要用 tRPC 的 SSR 功能,或者在 Turbopack 下运行,有些参数要微调;这里提醒一句,不要为了炫技把 webpack 配置改得太花,很多部署问题都出自过度配置。第三个是 tailwind.config.ts,模板已经按默认生成好了,除非你要改名支持的文件类型,否则几乎不用动。
2.4 环境变量模板:团队协作最容易翻车的地方
T3 模板默认带了一个.env.example,这是非常好但经常被忽略的设计。多人协作时,让每个新成员先cp .env.example .env,再填自己的值,比直接共享.env文件安全得多。后续我会在部署章节给出一份完整的检查清单表。这里先记住一个原则:.env永远不要提交进 Git 仓库,.env.example才是应该提交的那个。
3. 核心环节实操:数据库、鉴权与 tRPC 路由
3.1 从表结构开始:Prisma 模型设计与迁移
所有 T3 项目的起点都是 schema.prisma。一个博客类或笔记类的 t3code 项目,最常见的模型是这样:
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? posts Post[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Post { id String @id @default(cuid()) title String content String? published Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt author User @relation(fields: [authorId], references: [id]) authorId String }你可能会问,为什么 ID 不用自增整数而要cuid()?因为在多实例和分布式环境下,自增主键的冲突和预测性问题很麻烦,而 cuid 可以在应用层生成,天然适合前端预生成 ID 的场景。@updatedAt会自动维护更新时间,省掉手动赋值。至于content为什么可空,因为大多数列表页只需要标题和摘要,正文留空不会阻塞写入流程。
写完模型,执行:
npm run db:push这个命令把当前 schema 同步到数据库。如果是第一次,它会直接建表;后面改了字段,它会自动提醒你要不要重建。要注意db:push和migrate dev的区别:前者更像"直接把 schema 刷上去",适合开发初期的快速迭代;后者会生成迁移文件,适合多人协作和生产环境。到了正式项目,尽量用migrate dev。
3.2 接入 NextAuth:登录状态从无到有
T3 模板里选择 NextAuth 后,会在src/server/auth.ts生成配置入口,并且自动把相关路由接好。我们一般先配一个普通的 GitHub 或凭证登录做演示。在 auth.ts 里你会看到类似这样的结构:
import NextAuth from "next-auth"; import GitHub from "next-auth/providers/github"; import { PrismaAdapter } from "@auth/prisma-adapter"; import { db } from "@/server/db"; export const { handlers, auth, signIn, signOut } = NextAuth({ adapter: PrismaAdapter(db), providers: [GitHub], session: { strategy: "jwt" }, });adapter: PrismaAdapter(db)的作用是把登录后的用户信息自动写入数据库的 User 表。strategy: "jwt"表示用 JWT 做会话存储,不需要在数据库里维护 session 表;如果你想支持主动让用户下线,就改成数据库 session。这里要提醒一下:新版 NextAuth(Auth.js v5)把getServerSession替换成了auth(),很多老教程还在用旧写法,照着拷很容易报错。
如果你不打算接真实的第三方登录、只测流程,可以在 providers 里用CredentialsProvider,配一个写死的用户名密码,一天就能把整条链路跑通。但生产环境绝对不要用写死的密码做正式鉴权,这一点没有例外。
3.3 用 tRPC 把数据库访问函数化
tRPC 的核心概念其实特别少:一个 router 就是一组 procedure,procedure 分 query(查数据)和 mutation(写数据),缓存规则交给 React Query 管。写一个帖子相关的 router 大概是这么个样子:
import { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "@/server/api/trpc"; export const postRouter = createTRPCRouter({ latest: publicProcedure.query(({ ctx }) => { return ctx.db.post.findMany({ orderBy: { createdAt: "desc" }, take: 10, }); }), create: protectedProcedure .input(z.object({ title: z.string().min(1), content: z.string().optional() })) .mutation(async ({ ctx, input }) => { return ctx.db.post.create({ data: { title: input.title, content: input.content, authorId: ctx.session.user.id, }, }); }), });看到ctx.session.user.id了吗?这就是 protectedProcedure 的魔法所在。它的中间件会先检查 session,没登录直接抛错,登录了就把用户信息放到 context 里。你完全不需要在每个函数里写if (!session) throw这样的防御代码,安全策略从路由层面就控制住了。
zod 在这里扮演的是运行时校验器。T3 Stack 里,zod 校验通过的类型会被 tRPC 自动推导到前端。也就是说,前端调用 create 接口时,编辑器里自动就提示参数是{ title: string; content?: string },端到端的类型检查在这里真正闭合。
3.4 页面端调用:React Query 的缓存与失效
服务端写好了,前端调用也相当直观。直接在客户端组件里:
"use client"; import { api } from "@/trpc/react"; export function PostList() { const { data, isLoading } = api.post.latest.useQuery(); if (isLoading) return <div>加载中</div>; return ( <div> {data?.map((post) => ( <div key={post.id}>{post.title}</div> ))} </div> ); }api.post.latest.useQuery()之所以能直接链式调用,是因为 tRPC 会根据 router 的类型自动生成 hook。你会免费获得 React Query 的全部能力:请求去重、缓存、重试、窗口焦点重新请求。这些功能如果手写,一版至少几百行代码。
如果要做新增,记得在 mutation 成功后让缓存失效:
const utils = api.useUtils(); const createPost = api.post.create.useMutation({ onSuccess: () => { utils.post.latest.invalidate(); }, });invalidate很关键:它告诉 React Query 你之前的latest数据过期了,需要重新请求。没有这一步,列表不会自动刷新,你会误以为是 bug。这个"缓存失效"的思维,是习惯 REST 风格的人切换到 tRPC 后最需要适应的地方。
4. 常见问题与排查技巧实录
4.1 Prisma 环境变量相关的报错
最常见的报错是PrismaClientInitializationError:缺少环境变量。检查顺序一般是:确认.env存在、确认里面DATABASE_URL没被注释、确认.env在项目根目录而不是src下面。很多从旧教程复制过来的项目会把.env创建错位置,Prisma 在 monorepo 和 app 目录下找环境文件的规则又特别多,推荐直接看prisma/schema.prisma里url = env("DATABASE_URL")这一行,从这一行反推需要什么变量名。
另外,db:push和migrate dev的抉择:刚起步模型还在频繁改,用db:push很爽,但它会强制重建已有表,开发库无所谓,生产库千万别这么干。生产环境一律npx prisma migrate dev --name add_title生成迁移文件再执行,保留变更历史。我自己被生产库丢数据吓过一次之后,再也不敢在非环境上执行 push。
4.2 tRPC 前端类型不更新
修改了 router 后,前端经常出现类型不更新的现象。原因绝大多数是:tRPC 的类型是启动时快照的,dev server 在某些热更新场景下不会干净地重新生成类型。解决方法很简单,重启next dev,或者删掉.next目录再重跑。新版 create-t3-app 会通过postinstall自动做类型生成,所以你重启一次基本就解决了。
这个问题有个隐含经验:不要在编辑器里手工给 tRPC 的 hook 加类型标注。tRPC 的 hook 类型是自动推导出来的,任何手写类型都可能盖过推导值,导致报错信息完全看不明白。遇到类型诡异的地方,先去看 router 定义,而不是去看组件。
4.3 NextAuth 的 session 类型与 user.id 缺失
第三类高频问题。访问ctx.session.user.id报 "Property 'id' does not exist" 是很多人的第一课。原因很简单,默认的 NextAuth 类型里Session.user只有name、email、image。要扩展类型,新建一个src/types/next-auth.d.ts:
import { DefaultSession } from "next-auth"; declare module "next-auth" { interface Session { user: { id: string; } & DefaultSession["user"]; } }声明完之后,记得保证 tsconfig 把这个文件包含进去。你可能会踩的坑是:文件放在src/types,但 tsconfig 的include里没写这个目录,TS 就不会加载模块增强,依然报错。T3 模板一般会包含src/**/*.ts,所以放在 src 下通常都能生效。
另一个坑是 NextAuth 版本差异。getServerSession在旧版是主流,新版推荐用auth()。如果你在 App Router 的 Server Component 里用了旧 API,可能拿不到 session,表现为页面能跑但session.user始终是null。排查方法就是在auth()返回值里打印一下,看 token 是否正确解析。
4.4 Tailwind 生产构建后样式丢失
这个问题出现得很隐蔽:开发环境一切正常,npm run build后界面突然变素。原因通常是 Tailwind 的内容扫描范围不够,或者某个动态拼接的类名text-${size}被 Tree Shaking 删掉。Tailwind 无法在源码里找到完整类名字符串,这个类就不会被编译进产物。
修复办法有两个。第一,改用完整的静态类名映射表,比如const sizes = { sm: "text-sm", md: "text-md" };第二,在tailwind.config.ts的 content 数组里把你所有包含类名的文件路径都加进去。注意,content 配置默认会包含src/**/*.{ts,tsx},但如果是 monorepo 或者把组件放在了packages目录,要记得额外加包路径。类名拼接这个问题属于 Tailwind 官方文档反复强调的"不要动态拼类名",字面简单,实际写代码时一顺手就犯了,我起码被坑过三次。
4.5 环境变量在不同环境下的管理
开发、测试、生产的环境变量不一致,最容易在生产部署那一步炸掉。T3 模板默认带env.mjs校验机制,好处是少了某个变量构建会直接报错,坏处是报错信息中可能不够具体。排查时,我通常先把.env.example里的每一项都复制到目标环境的变量配置里,然后按顺序启动。
一个实操经验:不要在代码里到处直接调用process.env,而是通过src/env.mjs里导出的env对象来访问。这样变量名拼错了会立刻被类型检查发现,还能自动完成数据类型转换,比如端口号从字符串变成数字。T3 项目推荐的本来就是这套方式,如果没有特殊需求,模板已经帮你接好了。
5. 部署与上线:从本地到可以访问的细节
5.1 数据库与托管平台怎么选
t3code 这类全栈项目部署,核心是两件事:数据库可用、环境变量正确。表格列出几套我实际试过的方案:
| 方案 | 适用场景 | 优点 | 注意事项 |
|---|---|---|---|
| 本地跑 + SQLite | 学习阶段 | 零成本、启动快 | 不适合多人访问,数据存本地文件 |
| 托管 SQLite + 云平台 Serverless | 个人项目 | 部署快,自带 HTTPS | SQLite 是文件型,多实例读写有风险 |
| PostgreSQL + 云托管 | 正式项目 | 稳定、社区方案成熟 | 需要配好连接池和迁移流程 |
| Docker 自托管 | 私有部署 | 环境一致、迁移简单 | 要操心备份、日志和升级 |
我推荐第一次部署选"PostgreSQL + 托管"组合。等你被 SQLite 在并发写入时的锁冲突坑过一次,就明白这种推荐是有道理的。数据库选好后,把DATABASE_URL填成带连接池参数的完整连接串,不要用 localhost。
5.2 每次部署前的环境变量检查清单
我在线上环境翻车过太多次,后来做成了一张固定检查单,每次上线前照着过一遍:
| 变量 | 说明 | 必填 |
|---|---|---|
DATABASE_URL | 数据库连接串,需要允许远程连接 | 是 |
AUTH_SECRET | 会话签名密钥,生产环境绝对不能是默认值 | 是 |
AUTH_URL | 应用完整 URL,登录跳转时会用到 | 是 |
NEXT_PUBLIC_APP_URL | 前端需要使用的公开配置 | 视项目而定 |
不要偷懒把.env文件直接丢进部署平台。很多平台支持直接粘贴多行环境变量,粘贴时要注意换行符和特殊符号,比如数据库密码里的#或$需要小心转义。如果部署后登录失败,第一反应去看AUTH_SECRET是否在上传前后保持一致,这个字段不一致会导致所有 session 立刻失效。
5.3 构建阶段的几个隐形坑
构建时最常见的问题是内存不够。Next.js 构建本身吃内存,遇到 tRPC 路由多的项目,一个不注意就 OOM。如果你在服务器上构建,建议先给 Node 设一个合理的堆上限,或者在构建命令里显式指定内存限制。另外,构建机器和运行机器最好保持 Node 版本大版本一致,否则本地构建没问题、线上启动报 unknown option 的事情很常见。
还有一点经验:如果你用 Docker 部署,多阶段构建时,构建阶段用完整镜像跑npm run build,运行阶段用精简镜像跑node server.js,可以把最终镜像体积缩得很小。新版 Next.js 的output: 'standalone'输出模式已经能自动把必要文件集齐,建议配合 monorepo 场景把outputFileTracingRoot配好,避免文件追踪不全。
5.4 上线后的运维补充
上线不等于结束,数据库备份和日志是两件马上要做的事。PostgreSQL 的备份很简单,定时任务里跑pg_dump就好;SQLite 的话直接备份文件。日志方面,Next.js 的标准化日志本身够用,重点是把服务端报错接进一个统一的地方,用常见的错误聚合服务能省很多半夜被叫醒的时刻。
上线后第一个测试应该是:清空浏览器缓存,用无痕窗口访问一遍完整业务链路,重点看登录、写数据、列表刷新这三个动作。这个流程如果没走通,就说明部署环境还有配置问题,不要急着发公告让别人注册。
再说一个容易忽略的收益:部署阶段如果不再需要手写 Nginx 转发、不用自己配反向代理,就说明这套方案的托管度已经比较高了,后续迭代会轻松很多。
记得我第一次把 t3code 完整跑起来之后的感觉,跟以前前后端分开写完全是两回事。最有冲击力的不是少写了不少代码,而是我再也不用回忆接口到底叫getPost还是fetchPost了,tRPC 的类型推导直接把答案摆在你面前。
如果你准备拿这个项目做脚手架,我的建议是保留 tRPC + Prisma 这条主链路,它们才是类型安全的核心;样式层按团队习惯换掉完全没问题。踩过几次坑之后我最想强调的是,别把类型安全当成理所当然,prisma generate没跑、环境变量缺失、session 类型没扩展这几类问题,几乎每个 T3 新手都会经历一遍。最后再分享一个小技巧:给项目加 CI 时,优先跑一遍npx prisma generate && npx tsc --noEmit,这一条能拦掉 80% 的类型相关事故。