1. 从零搭建t3code:一个基于T3 Stack的全栈项目实践记录
我平时喜欢在 GitHub 上刷各类全栈项目,看到 t3code 这个名字第一反应就是 T3 Stack——TypeScript、Tailwind CSS、tRPC 三件套加上 Next.js 的那套组合拳。这套技术栈在圈子里讨论度一直很高,但很多人只是看了文档没真正上手跑过完整项目。这篇文章就用 t3code 这个实际项目,把我从初始化到部署的全过程、踩过的坑、还有对每个关键技术选型的思考,一次性讲清楚。如果你是刚接触全栈开发、想在 2025 年找一套能快速落地且类型安全的前后端一体化方案的人,这篇内容可以直接帮你省掉一个月的摸索时间。
先说结论:t3code 本质上不是某个公司出的框架,而是社区里对 T3 Stack 实践项目的统一叫法,也可以是你在本地随手建的一个实验仓库名。我这次把它做成一个带认证、数据库、API 路由和前端页面的完整示例项目,技术栈锁定 Next.js 14 + TypeScript + Tailwind CSS + tRPC + Prisma + SQLite,跑通从数据库到 UI 的完整数据流。下面所有内容都来自我实际敲过的命令和改过的代码,没水分。
2. 为什么选 T3 Stack:一次讲清楚这套技术组合的底层逻辑
2.1 T3 Stack 到底是什么,解决什么问题
T3 Stack 是 tRPC 作者 Theo 带起来的一套全栈开发方案,核心成员是 Next.js、TypeScript、Tailwind CSS、tRPC,通常还会配上 Prisma 和 NextAuth.js。它不是一个新的框架,而是把几个各自独立且优秀的工具组合成一个默认最佳实践。
很多人在选型时会纠结:前端用 React/Vue,后端用 NestJS/Express,数据库用 MySQL/PostgreSQL,然后再写一堆 REST API 或者 GraphQL 的 schema。问题在于,这套组合到了联调阶段,前端拿到接口文档,后端按文档返回数据,中间一旦字段类型对不上、参数名改了没同步,就会出各种 ghost bug。你半夜调接口,看到 undefined 报错,一层层往上翻,最后发现是后端字段拼错了,这种体验我相信大家都经历过。
T3 Stack 的思路是:既然前后端都用 TypeScript,那就让类型直接贯穿整个调用链,从数据库模型到后端路由再到前端调用,全部自动推导。数据库表结构用 Prisma 定义,tRPC 的 router 直接返回 Prisma 的查询结果,前端调用时不需要手写任何 interface 或类型声明,类型是跟着接口走的。数据模型一旦改了,编译期就能暴露所有调用方的错误,而不是等到运行时候才爆雷。
这个价值在单人全栈开发时感受最明显,因为一个人同时写前端和后端,最大的痛苦是脑子里要维护两套类型定义。我现在把 t3code 这个项目的运行流程拆开:浏览器请求一个页面 -> Next.js 服务端渲染时发起 tRPC 调用 -> tRPC 执行 query 函数 -> Prisma 查 SQLite -> 数据原路返回并自动带上完整类型。中间没有任何手工的类型断言,所有校验都在编译期完成。
2.2 为什么选 Next.js 而不是 Vite + Express
很多人问过我这个组合能不能换成 Vite 前端加 Express 后端,答案是能,但那会丢掉 T3 Stack 最大的优势:前后端一体化的开发体验与部署模型。
Next.js 提供了文件系统路由、服务端组件、API 路由、中间件,这些能力让 tRPC 可以直接挂在 Next.js 的 API 路由上运行,而不需要单独开一个后端服务。在 t3code 这个项目里,tRPC 的请求和普通的 API 请求一样走/api/trpc这个路径,但在代码层面,前端调用时是一个完全类型安全的函数,不是 URL 拼接。
这带来的实际好处:部署时可以只部署一个服务,不需要考虑前后端分离的跨域方案,不需要在 nginx 里配一堆代理规则,不需要维护两套环境的 CORS 配置。我本地起一个next dev,前端后端都在了。生产环境用next build && next start,也只有一个服务。这个心智负担的降低,对于小团队和个人项目来说是决定性的。
Vite 加 Express 的方案适合你本身就要拆分成独立前后端服务的场景,比如前端要独立部署到 CDN、后端要弹性扩容,或者前后端团队是不同的人维护。但如果你和我一样是单兵作战或者小团队快速验证,T3 Stack 的一体化模型会大大提高交付速度。
2.3 类型安全在 t3code 里具体是怎么闭环的
我来画一个实际的数据流,帮大家直观感受这套类型安全机制。在 t3code 里,我定义了一个帖子表:
model Post { id String @id @default(cuid()) title String content String published Boolean @default(false) createdAt DateTime @default(now()) authorId String author User @relation(fields: [authorId], references: [id]) }Prisma 会基于这个 model 自动生成完整的 TypeScript 类型,包含Post、PostCreateInput、PostWhereUniqueInput这些。然后我在 tRPC router 里写一个 query:
// server/api/routers/post.ts import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; export const postRouter = createTRPCRouter({ getAll: publicProcedure.query(async ({ ctx }) => { return ctx.db.post.findMany({ include: { author: true }, orderBy: { createdAt: "desc" }, }); }), create: publicProcedure .input(z.object({ title: z.string().min(1), content: z.string() })) .mutation(async ({ ctx, input }) => { return ctx.db.post.create({ data: { title: input.title, content: input.content, authorId: ctx.session?.user?.id ?? "anonymous", }, }); }), });前端调用时的体验是这样的:
import { api } from "~/trpc/react"; // 在客户端组件里调用 const { data: posts, refetch } = api.post.getAll.useQuery(); // 新增帖子 const createPost = api.post.create.useMutation({ onSuccess: () => refetch(), });整个过程里,posts的类型自动就是 Prisma 里findMany返回的完整结构,加上author的嵌套信息。如果哪天我改了 Prisma 的 model,比如给 Post 加一个views字段,那么重新运行prisma generate后,前端代码里posts的类型立刻会带上views,不需要手动同步任何 interface。
这套机制让我在 t3code 项目里删掉了几乎所有的interface Props和type ApiResponse声明文件。以前写 CRUD 项目,光是对接口类型就要写几百行,现在这些全自动了。省下的时间不是一星半点,尤其是后续重构的时候,你改数据库、改 router,编译器会告诉你哪些调用方要跟着改,杜绝了线上才发现的运行时错误。
3. t3code 实操:初始化、配置与核心代码细节
3.1 环境准备与脚手架初始化
我不建议从零手动去搭 Next.js 项目再慢慢装 tRPC,社区已经提供了创建 T3 Stack 项目的脚手架,一条命令搞定:
npx create-t3-app@latest t3code执行后脚手架会问你要装哪些模块,我的选择供参考:
- Next.js 版本:14(App Router)
- TypeScript:是
- Tailwind CSS:是
- tRPC:是
- Prisma:是
- NextAuth.js:是
- 数据库:SQLite(本地开发不折腾)
这里特别提醒一句,脚手架默认会让你选数据库,如果选了 SQLite,后面 Prisma 的配置会自动指向本地文件,零成本起步。如果一开始就选 PostgreSQL,本地还得额外起容器或者安装服务,对纯前端想快速验证的人来说门槛就高了。我的建议是初始先用 SQLite 跑通流程,项目上线或者数据量大了再迁移到 PostgreSQL,Prisma 的 schema 基本不用改,只需要改 datasource 里的连接串。
3.2 目录结构与关键文件解析
脚手架生成的项目结构在 App Router 模式下非常清晰,但有些细节新手容易迷路:
t3code/ ├── prisma/ │ └── schema.prisma # 数据库模型定义 ├── src/ │ ├── app/ │ │ ├── api/ │ │ │ └── trpc/ │ │ │ └── [trpc]/ │ │ │ └── route.ts # tRPC 的 HTTP 入口 │ │ ├── layout.tsx │ │ ├── page.tsx # 首页 │ │ └── _trpc/ │ │ └── Provider.tsx # tRPC 客户端 Provider │ ├── server/ │ │ ├── api/ │ │ │ ├── routers/ │ │ │ │ ├── post.ts # 帖子相关路由 │ │ │ │ └── root.ts # 路由聚合 │ │ │ └── trpc.ts # tRPC 初始化与上下文 │ │ └── auth.ts # NextAuth 配置 │ ├── trpc/ │ │ ├── client.ts # 客户端 tRPC 辅助函数 │ │ ├── react.tsx # React Hooks 封装 │ │ └── server.ts # 服务端 tRPC 调用 │ └── styles/ │ └── globals.css └── package.json最容易搞混的是src/server/api/trpc.ts和src/trpc/client.ts这两个文件。前者定义的是服务端的createTRPCRouter、publicProcedure这些基础对象,包含 Context 的构建逻辑;后者是给 React 前端用的客户端 wrapper,把 tRPC 的 hooks 和 types 都导出来。两者一个管后端能力,一个管前端调用,分工明确但名字相近。
3.3 核心链路实现:从数据库到 UI 的完整数据流
我拿 t3code 里最核心的"发帖展示帖列表"功能来走一遍完整链路,这也是多数学习者的第一个需求。
第一步:定义 Prisma Model
刚才已经给出了 Post 的模型定义,这里注意authorId关联到了User,但初始 schema 里可能没有 User 表,因为 NextAuth 默认会带一个 Account/Session/User 的模型。如果你没有选 NextAuth,需要自己补一个简单的 User 模型,否则 Post 的 relation 会报错。我这次选的就是带 NextAuth,所以 User、Account、Session 三张表现在已经存在。
第二步:在 tRPC router 里注册 query
回到刚才的 post router 代码。publicProcedure表示这个接口不需要登录就能访问。如果要加权限控制,用protectedProcedure,它内部会先校验 session,没有登录直接抛 UNAUTHORIZED 错误,前端 hooks 会把这个错误映射成 APIError 对象,在 onError 回调里统一处理跳转登录页,体验很好。
第三步:暴露 router 到根路由
root.ts的作用是聚合所有子 router:
// src/server/api/root.ts import { postRouter } from "~/server/api/routers/post"; import { createTRPCRouter } from "~/server/api/trpc"; export const appRouter = createTRPCRouter({ post: postRouter, }); export type AppRouter = typeof appRouter;这个AppRouter类型是整个类型安全的枢纽。客户端手写代码时不需要 import 这个类型,api.post.getAll这种链式调用的类型都是从AppRouter自动推导出来的。
第四步:HTTP 入口让请求到达 tRPC
脚手架已经生成好src/app/api/trpc/[trpc]/route.ts,它的作用是把所有发往/api/trpc/*的请求转发给 tRPC 处理器。这就是为什么前端没有任何 URL 字符串也能调用后端,因为 tRPC 客户端在初始化时已经知道了 endpoint:
// src/trpc/react.tsx export const api = createTRPCReact<AppRouter>(); // Provider 里配置了 links,默认 endpoint 是 /api/trpc第五步:前端页面调用
在page.tsx里使用服务端组件模式下怎么调 tRPC?这里有个容易踩的坑:直接用api.post.getAll.useQuery()是客户端 hooks 的用法,不能在服务端组件里用。服务端组件需要api.post.getAll.fetch()这种直接调用形式。我的实际代码:
// 服务端组件里获取数据 import { api } from "~/trpc/server"; export default async function HomePage() { const posts = await api.post.getAll.fetch(); // posts 的类型自动推导为带 author 的完整数组 return ( <div className="space-y-4"> {posts.map((post) => ( <div key={post.id} className="rounded-lg border p-4"> <h2 className="text-lg font-bold">{post.title}</h2> <p className="mt-2 text-gray-600">{post.content}</p> <span className="text-sm text-gray-400"> by {post.author.name ?? "匿名用户"} </span> </div> ))} </div> ); }注意api.post.getAll.fetch()的形参和返回值类型完全和服务端 query 的定义对齐,posts的每一项都有id、title、content、published、createdAt、authorId以及嵌套的author,编译器会强制你正确处理这些字段,妈妈再也不用担心我把post.author写成post.user。
第六步:写操作与自动刷新
页面上的表单提交用 mutation:
"use client"; import { api } from "~/trpc/react"; export function CreatePostForm() { const utils = api.useUtils(); const createPost = api.post.create.useMutation({ onSuccess: () => { utils.post.getAll.invalidate(); // 让 getList 自动重新拉取 }, }); return ( <form onSubmit={(e) => { e.preventDefault(); const formData = new FormData(e.currentTarget); createPost.mutate({ title: String(formData.get("title")), content: String(formData.get("content")), }); }} > <input name="title" placeholder="标题" className="border p-2" /> <textarea name="content" placeholder="内容" className="border p-2" /> <button type="submit" className="bg-blue-500 px-4 py-2 text-white"> 发布 </button> </form> ); }这里最有价值的是utils.post.getAll.invalidate()。以前写前端,发布完新数据要手动改本地 state 或者重新 fetch。现在一行代码,tRPC 会递归标记跟post.getAll相关的缓存为过期,下一次渲染会自动重新请求。这种按需失效的缓存策略比 React Query 默认的全量失效要精准得多,性能开销也更小。
3.4 认证集成:NextAuth.js 的坑与配置细节
t3code 项目里如果要加登录功能,脚手架已经集成好了 NextAuth.js,但默认配置里你要自己填AUTH_SECRET环境变量,否则启动时一堆红色警告。
我的 .env 文件:
DATABASE_URL="file:./db.sqlite" AUTH_SECRET="这里粘贴你生成的密钥"密钥生成命令:
openssl rand -base64 32NextAuth 配置里的 session 策略要选jwt,因为 tRPC 的 Context 里需要同步 session 信息。还有 Prisma adapter 的配置,主要是管理 Account/Session/User 三张表和 NextAuth 之间的映射。
在src/server/auth.ts里配置了 providers 后,tRPC Context 就可以拿到当前用户:
// src/server/api/trpc.ts export const createTRPCContext = async (opts: { headers: Headers }) => { const session = await getServerAuthSession(); return { db, session, ...opts, }; };这样在protectedProcedure里就能直接拿到ctx.session.user.id,做数据隔离或者权限判断都很方便。我实测下来,GitHub Provider 登录的流程最顺畅,只需要在 GitHub OAuth App 里设置http://localhost:3000/api/auth/callback/github这个回调地址就行。微信、Google 一些 Provider 在本地经常遇到重定向跳转问题,排查起来很费劲,新手建议先用 GitHub 或者干脆直接邮箱验证码,别把时间浪费在 Provider 配置上。
4. t3code 实际操作中遇到的问题与排查方法
4.1 tRPC 请求 404:检查 API 路由入口
这是我在本地一启动就遇到的第一个问题。页面正常渲染,但所有 tRPC 调用都是 404。后来发现原因是:App Router 版本的 create-t3-app 默认把 tRPC 入口放在/api/trpc/[trpc]/route.ts,但如果你的 Next.js 版本是 Page Router 结构(pages 目录),入口文件路径完全不一样。记住一个检查方法:浏览器直接访问http://localhost:3000/api/trpc/ping,React Query 的调用本质是一个 HTTP GET 请求,如果入口配置正确,这里应当返回一个 JSON 结构,里面会包含错误信息或者正常数据。如果返回 404,说明路由入口文件路径不对,或者你在自建项目时漏掉了route.ts的导出。
4.2 Prisma 初始化报错:表不存在?先跑 migrate
另一个常见问题:prisma generate生成了 client,但运行时查询报Table 'Post' doesn't exist。这是因为 Prisma model 只是定义,还没有映射到数据库表。需要在项目根目录执行:
npx prisma migrate dev --name init这个命令会根据 schema.prisma 自动生成迁移文件并同步数据库结构。如果用的是 SQLite,生成的数据库文件就在prisma/dev.db位置,你可以在 Prisma Studio 里可视化查看:
npx prisma studio浏览器打开http://localhost:5555就能看到所有表的记录,改数据很方便,比命令行一条条查快多了。
还有个小坑:schema.prisma 里的datasource块默认用的 provider 是sqlite,url = env("DATABASE_URL")指向file:./db.sqlite。但不同版本的连接字符串格式有细微差别,如果你用的是本地文件路径,不要加?connection_limit=1这种连接池参数,sqlite 不支持,会直接导致连不上。
4.3 React Query 的 type error:别忘了给 useQuery 传参
新手在客户端组件里写api.post.getById.useQuery()没传 ID,然后发现类型报错找不到useQuery的重载。其实不是 tRPC 的问题,是 getById 这个 procedure 需要一个 input,你可以在 useQuery 里传{ id: "xxx" },也可以把 input 置为undefined——但基于 tRPC 的类型安全,如果 query 需要 input 而你传了 undefined,类型层面会警告。最好的实践是在后端 router 定义时将 input 设为可选,或者统一用required: false的 zod schema。
我的实际做法是:需要列表页和详情页都查询同一个字段时,用getAll查列表,单独再写一个getById接受可选参数,内部判断。
4.4 Tailwind CSS 样式不生效
create-t3-app 的 Tailwind 已经预配置好了,但如果你中途升级或自己添加 PostCSS 插件,容易出现样式全部丢失的情况。检查tailwind.config.ts里的content数组是否包含了所有需要扫描的目录,典型的配置是:
content: ["./src/**/*.{js,ts,jsx,tsx}"],漏掉新加的页面目录,对应的类名就不会被编译。另外,如果你用了 CSS 变量覆盖主题色,注意:root变量在 globals.css 里要在@tailwind base之前声明,否则会被 preflight 覆盖掉。
4.5 常见问题速查表
| 现象 | 可能原因 | 排查路径 |
|---|---|---|
| tRPC 请求 404 | 入口文件路径错误或未导出 | 浏览器直连/api/trpc/xxx检查 JSON 响应 |
| Prisma 查询报表不存在 | 未执行 migrate | 执行npx prisma migrate dev |
| NextAuth 登录后 session 为空 | session 策略或数据库 adapter 配置错误 | 查服务端日志与 console |
| Tailwind 类名不生效 | content 扫描范围未覆盖 | 检查 tailwind.config.ts content 路径 |
| 部署后 tRPC 报 CORS | 前后端分离部署但未配跨域 | 将前后端部署在同一服务或域名下 |
第 5 条特别说明一下,T3 Stack 的一体化部署意味着你不应该把 Next.js 和 API 拆开跨域部署。如果你确实需要独立部署前端静态资源,那就需要给 API 路由单独配 CORS 中间件,建议直接用 Next.js 中间件函数加响应头来实现,不要图省事在组件里乱设 Access-Control-Allow-Origin。
5. t3code 中 Prisma 数据建模与迁移实战
5.1 从业务需求到数据模型
我在 t3code 里不止做了帖子功能,还加了标签系统和评论功能。这里我想重点讲一下数据建模的思考过程,因为大多数人知道怎么写 Prisma schema,但面对一个实际业务需求时容易纠结:要不要建关联表?用枚举还是字符串字段?要不要加默认值?
我的标签和帖子是多对多关系,Prisma 里直接用隐式多对多就够用:
model Tag { id String @id @default(cuid()) name String @unique posts Post[] } model Post { ... tags Tag[] }Prisma 会自动创建一张名为_PostToTag的关系表,不需要你手动管理。如果后续这张隐式关联表需要存额外字段(比如职位、权重),那就需要把它显式拆出来变成独立模型。我的判断标准很简单:只需要知道哪些帖子挂哪些标签,用隐式;需要在关联本身存业务信息(比如"谁给这个帖子打了标签"),用显式。
数据库索引方面,如果帖子列表经常按createdAt排序,就在 Post 模型里加:
@@index([createdAt])否则数据量大了之后一页一页翻列表,全表扫描会越来越慢。Prisma 对 SQLite 和 PostgreSQL 的索引语法通用,养成顺手加索引的习惯,后面省大量接口调优的功夫。
5.2 迁移流程的正确打开方式
在 t3code 里我经历了三次结构调整,每次都是同一个流程:
- 改
prisma/schema.prisma - 运行
npx prisma migrate dev --name description - 运行
npx prisma generate
migrate dev会生成 SQL 迁移文件到prisma/migrations/目录,同时自动执行迁移。这一步如果出现生产库和本地库的 schema 不一致,就会报警告。我建议本地开发时多建几个分支或环境独立的数据库,每条迁移记录保持可回溯的状态。
有个注意事项:migrate dev会触发prisma generate,然后你的 TypeScript 类型会自动更新,不需要每次手动跑 generate。但如果你的 IDE 里 TS Server 一直报旧类型错误,可以重启一次 TS Server,这是最常见的一个环境卡点。
5.3 种子数据怎么设计
开发阶段没有数据,页面空空荡荡,前端列表样式全部白调。我会在prisma/seed.ts里写一个播种脚本:
import { PrismaClient } from "@prisma/client"; const prisma = new PrismaClient(); async function main() { const user = await prisma.user.create({ data: { name: "demo_user", email: "demo@example.com", }, }); await prisma.post.createMany({ data: [ { title: "第一篇帖子", content: "用 t3code 搭建的第一个内容", authorId: user.id, published: true, }, ... ], }); } main().finally(() => prisma.$disconnect());然后 package.json 里配置:
"prisma": { "seed": "tsx prisma/seed.ts" }以后任何时候想要干净的数据环境,跑一下npx prisma db seed就能重建基础数据。分享一个小技巧:种子脚本里用deleteMany清空老数据时,如果外键约束冲突,执行顺序要颠倒一下,先删子表再删父表。SQLite 默认不强制外键,但 PostgreSQL 会严格检查,写种子脚本时养成"反序删除"的习惯更稳。
6. t3code 项目优化与部署实战
6.1 包体积分析与 tree-shaking
Next.js 14 默认有很好的 tree-shaking 能力,但 tRPC 的服务端代码和客户端代码混在同一个项目里,打包时略有不注意就会把 Prisma Client 全部打进前端 bundle。我实际检查过next build的产物报告,发现只要在客户端组件里不小心导入了服务端的db或appRouter,Prisma 就会被拉到浏览器 bundle,体积直接暴涨几 MB。
检查方法:
ANALYZE=true next build这需要你安装@next/bundle-analyzer。然后逐个看 chunk 大小,凡是包含prisma、@prisma/client的 chunk 都要警惕。正常情况 Prisma 只应该在服务端 bundle 出现。
另外尽量不在_trpc/Provider.tsx这种顶层组件里 import 额外的内容。tRPC 客户端 wrapper 保持精简,除了createTRPCReact、httpBatchLink这些必要依赖外,不再放别的业务逻辑,否则所有页面都会背着这部分负担。
6.2 缓存策略:怎么让 tRPC 复用数据而不是每次刷新都打接口
React Query 的缓存机制默认对 GET 请求是生效的,staleTime 默认为 0,意味着每次进入页面都会重新请求,但如果你希望列表 30 秒内不重复拉接口,可以在 Provider 里统一配置:
// src/trpc/react.tsx return ( <api.Provider client={trpcClient} queryClient={new QueryClient({ defaultOptions: { queries: { staleTime: 30 * 1000, refetchOnWindowFocus: false, }, }, })} > {children} </api.Provider> );refetchOnWindowFocus默认是 true,频繁在窗口之间切换会导致接口请求风暴。我一般开发环境开着方便看实时数据,生产环境直接关掉。staleTime设得越长,服务器压力越小,但数据新鲜度越低,要根据业务类型权衡。比如帖子列表 30 秒刷新一次可以接受,用户余额这种请求就别缓存了。
6.3 部署到 Vercel 的完整配置
T3 Stack 项目部署到 Vercel 非常顺手,但有几个必须注意的点。
先在 Vercel 后台的 Project Settings 里配置环境变量,把.env里的DATABASE_URL和AUTH_SECRET抄过去,注意生产环境别用 SQLite 文件,因为 Vercel 的无服务器环境没有持久磁盘,文件会被重置。我的建议是生产环境直接上 Vercel Postgres 或者 Neon 的免费 PostgreSQL 实例,把连接串换一下即可。
DATABASE_URL="postgresql://user:password@your-neon-host/t3code?sslmode=require"然后确保 rebuild 脚本包含 migration 步骤。我平时用:
npx prisma migrate deploy && next buildmigrate deploy会执行所有未执行的迁移文件,但不会修改 schema,所以它比migrate dev更适合生产环境。本地加字段可以随便跑 dev,但生产环境没有交互提示,deploy 才是安全操作。
Vercel 的部署流程我实测下来只需要 3 分钟:Git 推送之后 Vercel 自动识别 Next.js 项目,执行 build 命令,完成后自动分配一个 URL。唯一想吐槽的是国内访问 Vercel 有时不够稳,如果你主要面向国内用户,那就要备选其他平台了。
6.4 Docker 部署:把 t3code 装进容器
如果你的项目需要自己控制服务器,Docker 部署是更主流的选择。我为 t3code 写了一个极简 Dockerfile:
FROM node:20-alpine AS base WORKDIR /app FROM base AS deps COPY package.json pnpm-lock.yaml* ./ RUN corepack enable && pnpm install --frozen-lockfile FROM base AS builder COPY --from=deps /app/node_modules ./node_modules COPY . . RUN npx prisma generate && pnpm build FROM base AS runner RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs COPY --from=builder /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next COPY --from=builder /app/node_modules ./node_modules USER nextjs EXPOSE 3000 CMD ["pnpm", "start"]注意 Prisma 在容器构建阶段必须执行prisma generate,否则运行时的 Prisma Client 缺少查询引擎,会直接报错。此外容器里如果用 SQLite,要把数据库文件放到持久化卷,否则容器重启就清空。
这里要特别说明的是 Dockerfile 这种多阶段构建虽然看起来多写了几个 COPY,但每一层都有它的用途:deps阶段只装依赖作为构建缓存,builder阶段做完整构建,runner阶段尽量精简,只保留运行必需的文件。这样镜像体积可以控制在 300MB 以内,而如果不分层地直接把 node_modules 扔进去,每次构建都要重装依赖,体积可能轻松超过 1GB。
7. 我的实操心得:t3code 项目带给我的四个改变
写了这么多,把最实际的感受和总结放在最后,这也是我在整个过程中踩坑后得出的经验。
第一,类型安全不是束缚,是最大提速引擎。t3code 开发后期我经常一次性改掉七八个文件的代码,因为有编译器兜底,我敢大胆重构。以前写后端接口,改一个字段名要全局搜索替换,现在改完 Prisma model,所有引用点暴露出红色波浪线,跟着修完基本不会漏。
第二,别让工具链反过来主导你的业务设计。T3 Stack 的脚手架虽然方便,但默认的全家桶不是所有项目都需要。如果你只是一个简单博客,不需要 tRPC,直接用 Next.js 的 RSC 或者 API route 就够了。t3code 这个项目之所以适合用全套 T3 Stack,是因为它核心特性就是类型安全的 CRUD 交互,tRPC 的收益才最大。
第三,数据库选型别从入门就上分布式数据库。本地开发 SQLite,云端用 PostgreSQL,这个组合在 T3 Stack 生态里是最平滑的。你想要多租户、实时订阅这些能力,应该是在业务真正需要时再引入,而不是一开始抱着完美主义心态上重型装备。
第四,多参考社区真实的 T3 Stack 项目,而不是官方文档。官方文档偏 API 说明,缺少实际业务的整合。我在做 t3code 时参考了不少开源项目,看它们如何处理认证、权限、文件上传、错误边界,收获比单纯读文档大得多。如果你也想找一个合适的项目练手,直接搜 GitHub 上create-t3-app相关的高星仓库,克隆下来改一改,比从零写更有感觉。