做全栈开发这几年,我越来越觉得“类型安全”不是锦上添花,而是命根子。t3code 这个名字听起来像个编码玩具,实际上是我在反复折腾 T3 Stack 之后沉淀下来的一个全栈 TypeScript 项目模板。简单说,它把 Next.js、tRPC、Prisma、Tailwind 这些工具拧成一股绳,让你不用在类型定义、API 路由、数据库模型之间来回切换,就能把前后端完整串联起来。
很多刚接触的人会问:这跟 create-t3-app 有什么区别?我自己的定位很明确,t3code 更像一个“开箱即用的最小化版本”——去掉了一堆用不上的配置和演示代码,保留核心链路,同时加入了我实际项目里验证过的目录约定、错误处理思路和部署经验。这篇文章会把整个项目的设计逻辑、核心实现、实操步骤和踩过的坑全部拆开来讲,适合已经会 TypeScript 基础、想快速上手现代全栈开发,或者正在纠结技术选型的同学。
1. 项目概述:一个“类型安全优先”的全栈样板
1.1 从 T3 生态说起:为什么是 TypeScript First
T3 这个词最早来自 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.db,Prisma 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。如果去掉--name,Prisma 会进入交互式命名,容易卡住,所以我建议每次都给迁移起一个简短名字。迁移之后,还要注意在 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.context<typeof 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 className="mx-auto max-w-2xl p-6"> <h1 className="text-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: Awaited<ReturnType<typeof import("~/server/api/root")["api"]["note"]["listMine"]>> }) { const { data, refetch } = trpc.note.listMine.useQuery(undefined, { initialData: initialNotes, }); return ( <ul className="space-y-4"> {data?.map((note) => ( <li key={note.id} className="card"> <div className="flex items-center gap-2"> <span className="font-semibold">{note.title}</span> {note.tags.map((tag) => ( <span key={tag.id} className="rounded bg-blue-100 px-2 py-0.5 text-xs"> {tag.name} </span> ))} </div> <p className="mt-2 text-sm text-gray-600">{note.body}</p> </li> ))} <button onClick={() => refetch()} className="mt-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里拿到 session:
import { 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 下来之后一条命令就能进入开发状态。这个习惯看起来不起眼,却能省掉团队里大量的“环境类”沟通成本。