做全栈开发这几年我越来越觉得“类型安全”不是锦上添花而是命根子。t3code 这个名字听起来像个编码玩具实际上是我在反复折腾 T3 Stack 之后沉淀下来的一个全栈 TypeScript 项目模板。简单说它把 Next.js、tRPC、Prisma、Tailwind 这些工具拧成一股绳让你不用在类型定义、API 路由、数据库模型之间来回切换就能把前后端完整串联起来。很多刚接触的人会问这跟 create-t3-app 有什么区别我自己的定位很明确t3code 更像一个“开箱即用的最小化版本”——去掉了一堆用不上的配置和演示代码保留核心链路同时加入了我实际项目里验证过的目录约定、错误处理思路和部署经验。这篇文章会把整个项目的设计逻辑、核心实现、实操步骤和踩过的坑全部拆开来讲适合已经会 TypeScript 基础、想快速上手现代全栈开发或者正在纠结技术选型的同学。1. 项目概述一个“类型安全优先”的全栈样板1.1 从 T3 生态说起为什么是 TypeScript FirstT3 这个词最早来自 Theo 提出的 T3 Stack核心思想一句话能类型安全的地方就绝不放过。传统前后端分离项目里前端用 TypeScript后端用 Java 或 Python接口层面全靠手写文档和 Postman 确认类型在边界处就断了。t3code 把整条链路都放在 TypeScript 的覆盖范围里从数据库表到 API 路由到前端组件所有东西共享一套类型系统。这个理念我觉得真的是能改变开发习惯的。以前我写接口第一件事是打开 Swagger 看字段然后手动定义 DTO再写 axios 请求类型最后后端改了一个字段名前端毫无感知跑到线上才发现。用了 t3code 的链路之后最直观的感受是“改完数据库模型前端马上报类型错误”报错的位置就是需要修改的位置几乎不会出现隐藏的字段错位问题。这套组合拳具体是Next.js 负责页面和 API 宿主tRPC 负责处理客户端到服务端的过程调用Prisma 负责数据库建模和查询Tailwind 负责样式。如果从零开始手动集成光是配置 babel、TS 路径、React Query、类型引用就要花一下午。t3code 把这些全部预配置好本地启动五分钟之内就能跑起来。1.2 这个项目适合谁以及能省下多少工作量我建议这几类人可以直接拿 t3code 当起点想做全栈作品集或 MVP 的独立开发者不想维护两套项目结构。团队里已经统一使用 TypeScript但前后端契约总是出问题的项目组。想系统学 tRPC、Prisma 和 Next.js 配合方式的进阶前端学习者。做个不严谨的估算如果不用任何模板从零搭建一个包含数据库、接口、鉴权、前端页面和样式系统的全栈项目正常情况下需要两天到三天。用 t3code 大概半天就能把骨架跑通后面所有时间都花在业务逻辑上。省掉的部分不是“偷懒”而是把那些与业务无关的重复决策比如“React Query 放哪个目录”“Prisma client 怎么初始化”“错误边界怎么统一处理”提前固化成最佳实践。2. 技术架构与核心设计思路2.1 为什么选择 Next.js App Router 作为宿主t3code 最初用的是 Pages Router后来我把默认分支切到了 App Router。原因不只是官方推新不用旧而是 App Router 的 Server Component 加 tRPC 的 Server Caller能实现一个很舒服的模式页面首屏需要的数据直接在服务端调用 tRPC 过程不用经过 HTTP 请求。这个设计在传统模式下非常别扭。以前用 Next.js Pages Router 写 SSR要么绕过 tRPC 直接查 Prisma要么在getServerSideProps里手写 fetch 调到自己的 API来回绕一圈还增加延迟。App Router 里可以直接把 tRPC router 实例导入 server component像调用本地函数一样拿到数据。这个改动让代码结构更干净也几乎没有网络开销。当然 App Router 也有一些心智负担比如“use client”边界。t3code 的做法是把所有需要交互的组件都排除到客户端静态展示部分尽量留在服务端。这样权限校验、数据预取、SEO 相关的逻辑都能在服务端完成浏览器拿到的 HTML 首屏内容更完整白屏时间也更短。2.2 tRPC 如何把后端变成“本地函数调用”tRPC 的核心原理其实是利用 TypeScript 的类型推断把客户端调用包装成一个普通的trpc.post.list方法。它没有像 GraphQL 那样发明一套 DSL也没有像 REST 那样手动定义每个 URL而是通过一个createTRPCClient把服务端 router 的类型信息传递给前端。从服务端看你只需要定义一个 router// server/routers/note.ts import { z } from zod; import { createTRPCRouter, publicProcedure } from ../trpc; export const noteRouter createTRPCRouter({ list: publicProcedure.query(async ({ ctx }) { return ctx.db.note.findMany({ orderBy: { updatedAt: desc }, }); }), create: publicProcedure .input(z.object({ title: z.string().min(1), body: z.string() })) .mutation(async ({ ctx, input }) { return ctx.db.note.create({ data: input, }); }), });前端调用时不需要手动定义任何接口类型// components/NoteList.tsx use client; import { trpc } from ~/trpc/react; export function NoteList() { const { data, refetch } trpc.note.list.useQuery(); return ( ul {data?.map((note) ( li key{note.id}{note.title}/li ))} /ul ); }一旦后端note.list的返回类型变了前端data的类型立刻报错。这种“编译器驱动开发”的方式比任何代码 review 都管用。你不用担心写了类型错误因为类型系统会主动跳出来提醒你。2.3 Prisma SQLite 的快速启动方案t3code 默认使用 SQLite原因很简单零配置、单文件、适合开发和小型生产。如果业务规模上来Prisma 迁移到 PostgreSQL 的成本非常低只需要改数据源provider和连接字符串模型本身几乎不用动。prisma/schema.prisma里几个关键点datasource db { provider sqlite url env(DATABASE_URL) } generator client { provider prisma-client-js } model Note { id String id default(cuid()) title String body String createdAt DateTime default(now()) updatedAt DateTime updatedAt }这里要注意SQLite 的env(DATABASE_URL)在.env里写的是file:./dev.db而不是sqlite://。我在第一次搭建时卡了十分钟就是因为连接字符串写错导致 Prisma 找不到数据库文件。2.4 Tailwind 与样式组织逻辑Tailwind 在这套项目里承担的是“快速出界面”的角色。我见过很多团队纠结 Tailwind 到底会不会让 HTML 变乱实际用下来只要约定好原子类名的分组顺序可维护性并不比 CSS Modules 差。t3code 里我做了两个约定一个是布局相关的类统一放在外层容器上颜色和间距放在内层组件上另一个是通用的按钮、输入框、卡片样式抽成layer components避免每个页面重复写一长串 class。// app/globals.css 片段 layer components { .card { apply rounded-xl border border-gray-200 bg-white p-6 shadow-sm; } }这样既能享受原子类带来的开发效率又保留了样式复用的基础。如果你喜欢 CSS 变量或者想要暗色模式直接在tailwind.config.ts里扩展主题变量即可整个结构不会被打乱。3. 实操用 t3code 快速搭建一个笔记应用3.1 初始化与目录结构从模板初始化项目我用的是 Turborepo 的 monorepo 风格也可以直接单包运行。整个目录结构如下t3code/ ├── apps/ │ └── web/ │ ├── app/ │ │ ├── layout.tsx │ │ ├── page.tsx │ │ └── notes/ │ │ └── page.tsx │ ├── components/ │ ├── server/ │ │ ├── api/ │ │ │ └── trpc/ │ │ │ ├── router.ts │ │ │ └── trpc.ts │ │ └── db.ts │ ├── trpc/ │ │ ├── client.ts │ │ └── react.tsx │ ├── styles/ │ ├── package.json │ └── tailwind.config.ts ├── packages/ │ └── database/ │ ├── prisma/ │ │ └── schema.prisma │ └── src/ │ └── index.ts ├── .env └── pnpm-workspace.yaml我用 pnpm workspace 来管理依赖主要好处是本地开发时的依赖提升和缓存都很快尤其 Prisma 这种原生模块比较多的时候pnpm 安装速度明显优于 npm。初始化命令很简单pnpm install pnpm dev默认跑在http://localhost:3000。第一次启动会提示你创建dev.dbPrisma client 会自动生成不用额外手动执行 miration因为模板里已经连了一个prisma db push的 preset 脚本。如果你是在一个已有数据库上改模型后面再走prisma migrate dev。3.2 数据库模型与 Prisma 迁移以笔记应用为例我增加了用户字段和标签字段用来演示一对多和多对多关系model User { id String id default(cuid()) email String unique name String? notes Note[] createdAt DateTime default(now()) } model Note { id String id default(cuid()) title String body String authorId String author User relation(fields: [authorId], references: [id]) tags Tag[] createdAt DateTime default(now()) updatedAt DateTime updatedAt } model Tag { id String id default(cuid()) name String unique notes Note[] }模型建好后执行pnpm prisma migrate dev --name add_user_tag这条命令会生成一个 SQL 迁移文件同时重新生成 Prisma Client。如果去掉--namePrisma 会进入交互式命名容易卡住所以我建议每次都给迁移起一个简短名字。迁移之后还要注意在 t3code 的全栈类型链路里Prisma 模型的字段变更会直接影响 tRPC 过程里ctx.db的返回类型。如果你在页面里用了某个字段但模型改掉之后忘记更新调用处编译器就会在pnpm type-check阶段直接告诉你哪里出错了。3.3 写一个 tRPC 路由t3code 的过程procedure有三种常用形态query、mutation和subscription。笔记应用主要用到前面两种。先做一个带鉴权的路由。因为模板里默认没有额外配鉴权库我用一个很简单的“当前用户ID”走上下文注入生产环境建议换成 NextAuth 或 Clerk这里先保持核心链路清晰// server/api/trpc.ts import { initTRPC, TRPCError } from trpc/server; import { db } from ../db; export const createTRPCContext async () { return { db, userId: default-user }; }; const t initTRPC.contexttypeof createTRPCContext().create(); export const createTRPCRouter t.router; export const publicProcedure t.procedure; export const protectedProcedure t.procedure.use(({ ctx, next }) { if (!ctx.userId) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { ...ctx, userId: ctx.userId } }); });然后是笔记的路由// server/api/routers/note.ts import { z } from zod; import { createTRPCRouter, protectedProcedure } from ../trpc; export const noteRouter createTRPCRouter({ listMine: protectedProcedure.query(async ({ ctx }) { return ctx.db.note.findMany({ where: { authorId: ctx.userId }, include: { tags: true }, orderBy: { updatedAt: desc }, }); }), create: protectedProcedure .input( z.object({ title: z.string().min(1, 标题不能为空), body: z.string(), tagNames: z.array(z.string()).optional(), }) ) .mutation(async ({ ctx, input }) { return ctx.db.note.create({ data: { title: input.title, body: input.body, authorId: ctx.userId, tags: input.tagNames?.length ? { connectOrCreate: input.tagNames.map((name) ({ where: { name }, create: { name } })) } : undefined, }, }); }), });这里有个小细节connectOrCreate的where要求标签必须有唯一约束所以Tag.name必须设成unique。如果没有约束Prisma 在运行时会报 P2002 错误这也是新手比较容易踩的坑。3.4 前端页面与 API 联动前端通过trpcclient 发起调用。在 App Router 页面里我习惯先写一个服务端组件做数据预取再把数据传给客户端组件。// app/notes/page.tsx import { NoteList } from ~/components/NoteList; import { api } from ~/server/api/root; import { createTRPCContext } from ~/server/api/trpc; export default async function NotesPage() { const ctx await createTRPCContext(); const caller api.createCaller(ctx); const notes await caller.note.listMine(); return ( div classNamemx-auto max-w-2xl p-6 h1 classNametext-2xl font-bold我的笔记/h1 NoteList initialNotes{notes} / /div ); }客户端组件接收初始数据后再通过 React Query 维护缓存和重新请求// components/NoteList.tsx use client; import { trpc } from ~/trpc/react; export function NoteList({ initialNotes }: { initialNotes: AwaitedReturnTypetypeof import(~/server/api/root)[api][note][listMine] }) { const { data, refetch } trpc.note.listMine.useQuery(undefined, { initialData: initialNotes, }); return ( ul classNamespace-y-4 {data?.map((note) ( li key{note.id} classNamecard div classNameflex items-center gap-2 span classNamefont-semibold{note.title}/span {note.tags.map((tag) ( span key{tag.id} classNamerounded bg-blue-100 px-2 py-0.5 text-xs {tag.name} /span ))} /div p classNamemt-2 text-sm text-gray-600{note.body}/p /li ))} button onClick{() refetch()} classNamemt-4 rounded bg-blue-600 px-4 py-2 text-sm text-white 刷新 /button /ul ); }这里可以注意到initialNotes的类型是从服务端 router 的返回类型直接推导出来的不需要单独定义接口。这种“类型跟着路由走”的体验是我用 t3code 后最上头的部分。3.5 补充与认证中间件的配合如果你要接入真实认证t3code 预留了一个方便扩展的位置createTRPCContext里读取请求头或 session然后在protectedProcedure里判断用户。以 NextAuth 为例可以在app/api/auth/[...nextauth]/route.ts配置回调然后在createTRPCContext里拿到 sessionimport { getServerSession } from next-auth; import { authOptions } from ~/app/api/auth/[...nextauth]/route; export async function createTRPCContext() { const session await getServerSession(authOptions); return { db, userId: session?.user?.id ?? null, }; }这样只动一个文件就可以让所有 protected 路由都强制要求登录状态不需要在各处重复写权限判断。4. 部署后的经验教训常见问题与排查实录4.1 tRPC 类型不匹配的排查思路tRPC 类型推导虽然强但经常遇到前端老是报类型错误的场景。最常见原因是后端 router 里改了过程名或入参结构但前端没有同步。绝大多数时候跑一遍pnpm type-check就能列出来。但有一种隐蔽情况服务端返回的Date对象在客户端变成了字符串。因为数据经过了序列化tRPC 默认会把Date转成 ISO 字符串而类型系统里依然是Date。你如果不做处理直接对返回字段调用.toLocaleDateString()浏览器就会运行时报错。解决思路有两个一是前端统一用date-fns的format函数处理先转字符串再格式化二是在 tRPC 的 transformer 里换成 SuperJSON它能完整保留Date、Map、Set等类型。我目前默认用 SuperJSON代价是序列化体积稍大但对于笔记、博客这类项目完全能接受。4.2 Prisma 重新迁移的坑开发阶段最痛苦的事就是反复改模型尤其是改字段名或删除字段时prisma migrate dev会创建一个新的迁移但如果上一个迁移还没提交容易产生冲突。我在一次改动里把Note.body改成了Note.content结果 miration 文件里既有新增列又有删除列SQLite 对 drop column 支持不完整导致数据库被锁。查了半天最后直接把开发数据库删掉重新执行prisma db push才恢复正常。经验就是开发环境不要吝啬重建数据库。只要没有需要手工导入的真实数据遇到迁移异常就大胆rm -rf prisma/dev.db*后重新prisma db push。如果已经上线了那就要老老实实写迁移脚本每一步都要验证 down 方向的回滚语句。4.3 部署到 Vercel 时的小细节t3code 部署到 Vercel 整体很顺但有三个细节必须注意。第一SQLite 文件不要放在持久化要求高的应用里。Vercel 的无服务函数是临时文件系统prisma/dev.db在每次部署后都是全新的。你可以在本地测试时用 SQLite上线就接一个 PostgreSQL环境变量切换非常简单。如果预算有限也可以用自己的服务器长期运行 Node 服务把 SQLite 文件放在磁盘上同时做好备份。第二Prisma Client 在无服务环境需要指定二进制目标。我的做法是在schema.prisma里加上generator client { provider prisma-client-js binaryTargets [native, linux-musl-openssl-3.0.x] }否则部署后执行数据库查询会报找不到引擎的错误。第三环境变量命名必须统一。t3code 里同时出现DATABASE_URL和NEXTAUTH_SECRET在 Vercel 后台都要配好。我吃过一次亏本地.env有DATABASE_URL但线上忘了加结果所有接口都 500而且日志里只有一段看不懂的 Prisma 连接错误排查了小半天才发现。4.4 性能优化的三个小技巧项目上线后我针对 t3code 做了三轮优化分享三个最有效的小技巧用 React Query 的 staleTime 控制重新请求频率。列表类页面如果一分钟内不需要最新数据就把staleTime设为 60_000。tRPC 默认是每次挂载都重新请求对无服务环境来说这是完全没有必要的开销。避免在 Server Component 里导入客户端组件时把整包 bundle 变大。尽量把不依赖状态的图表、富文本编辑器等组件用next/dynamic动态加载并设置ssr: false。t3code 模板里默认trpc/react是纯客户端模块如果你在服务端组件里引入它会明显增加包体。数据库索引一定要建。笔记列表按updatedAt排序就必须给updatedAt加索引。数据量小的时候无所谓一旦数据到几千条排序速度会肉眼可见地卡顿。model Note { // ... updatedAt DateTime updatedAt index }只加一个索引查询耗时的变化非常明显。5. 我对 t3code 后续扩展的一些想法t3code 现在只是把全栈核心链路打通了后续如果还要加功能我建议优先考虑这几个方向多租户支持。在 User 模型上加一个orgId所有数据查询都按组织过滤这样就能从单用户笔记应用变成小型协作工具。事件日志。用 Prisma 加一个AuditLog模型在 tRPC 的 middleware 里记录每次 mutation 的操作人和时间。这个功能实现简单但对排查线上问题帮助巨大。实时协同。当前 t3code 用的是 query/mutation 模式但 tRPC 也支持 subscription。可以接 WebSocket 让笔记列表在多个客户端之间实时同步。不过要注意部署到 Vercel 这类无服务平台时WebSocket 的长期连接支持有限建议用独立的 Node 服务器或第三方实时服务。另外一个我在实际使用中的体会是模板简化到一定程度后反而更容易被人理解。t3code 没有把一堆 demo 功能塞进去而是保留了一条清晰的主线“数据库 → 路由 → 前端”。新成员加入项目时只要沿着这条主线读代码五分钟就能定位到要改的地方。这比复杂的架构文档管用得多。最后再分享一个小技巧如果你打算长期维护这个项目一定要在README里写清楚“如何从零初始化数据库”。我见过太多项目因为文档缺失同事们只能靠猜来重建开发环境。t3code 在scripts里固定了db:init、db:reset、type-check三条命令任何人 clone 下来之后一条命令就能进入开发状态。这个习惯看起来不起眼却能省掉团队里大量的“环境类”沟通成本。