干这行这么多年,我最怕的不是业务逻辑写不完,而是每开一个新项目都要把老一套东西重新搭一遍:初始化工程、配路由、连数据库、写鉴权中间件、设计 API 协议……少则半天,多则两三天,光是重复劳动就把热情磨掉一半。所以很长时间里,我一直在折腾一件事:能不能把一套自己顺手的技术栈固化成一个模板,新项目拉下来改改业务就能直接跑。t3code 就是在这个思路下慢慢长出来的项目。
t3code 不是一个商业产品,也不是什么大厂开源框架,它是一套面向全栈开发者的现代 Web 应用样板工程。核心解法很简单:用 TypeScript 贯穿前端、后端和数据库访问层,借助 Next.js App Router 做前端渲染与后端 API 的宿主环境,配合 tRPC 实现端到端的类型安全调用,数据层用 Prisma 管理模型和迁移。前端样式统一走 Tailwind CSS,不搞花哨的组件库,只留一套原子化的设计基础。项目目标也直白:让“类型安全”成为默认选项,让新项目能在一个小时内跑起来并且达到可上线的工程标准。
这篇文章写给谁?如果你是刚接触全栈开发、被各种技术选型搞得晕头转向的新人,它会告诉你为什么类型安全值得认真对待、一套样板工程的结构为什么长成这样。如果你已经有几年经验、想摆脱项目初始化恐惧症,那这里面的目录设计、配置细节和避坑记录应该能直接帮你省下不少时间。我尽量把当初踩过的坑、对比过方案之后的取舍都写进去,说得不藏着掖着。
1. 项目概述:t3code 到底解决什么问题
1.1 全栈样板工程的核心思路
理解一个样板工程,别先看代码,先看它想消灭什么痛苦。我总结下来就三类:类型不同步、请求协议不统一、项目结构随缘长。
类型不同步是最大的隐性成本。以前写前后端分离项目,后端定义好接口返回一个 JSON,前端靠“约定”知道字段是什么。后端把username改成nickname,前端编译不会报错,直到运行时才发现数据拿不到。更麻烦的是数据库字段调整,后端查出来什么结构、前端用了什么结构、中间请求层有没有做映射,三处信息全靠人肉对账。t3code 的思路是让这三处由同一个类型源头推导出来,改了一处,其余两处编译期立刻报错,从根上消掉这一类问题。
请求协议不统一也很常见。有人用 REST,有人用 GraphQL,同一个项目里两种风格混着用。REST 的核心问题是每个接口都要自己定义 URL、方法、请求体、响应体,文档一缺就完全靠猜。GraphQL 确实解决了一部分数量的问题,但它的学习成本、缓存策略和权限设计都比表面看起来复杂,小团队很容易hold不好。t3code 选择 tRPC 走的是另一条路:不发明新协议,直接把后端函数暴露给前端,类型从函数签名自动推导。前端调后端写起来跟调用本地函数差不多,但网络传输还是通过 HTTP 完成,JSON 序列化也照常。
项目结构随缘长是第三个痛点。今天新项目从src/pages开始,明天又看到有人推荐src/app,后天再来个 monorepo 套 pnpm workspace。不是说不该升级,而是大多数项目根本没到需要复杂结构的规模。t3code 的结构规则很简单:约定大于配置,目录角色单一,新代码往哪里放不需要纠结,放错位置一眼就能发现。
1.2 为什么选择这套技术组合
选型这块我想多说一点,因为很多人抄作业只抄结论,不抄思考过程。Next.js 做框架层主要图两点:一是 App Router 天然把前端页面和后端 API 放在同一种路由心智模型里,二是它的 RSC(React Server Components)让我可以把数据库查询直接放在组件里做,省掉一层多余的 API 封装。有人会担心这样把前后端耦合死了,但 t3code 的目标本来就不是“可替换前端”,而是“最少的代码量完成全栈开发”。如果你想保留后期接移动端的能力,tRPC 可在 server 层单独暴露 HTTP 入口,并不影响复用。
数据库访问层选了 Prisma,原因是它的 schema 文件提供了整个项目最核心的“类型单一真源”。定义好schema.prisma,Prisma Client 会自动生成完整的 TypeScript 类型,包括表关系、枚举值、分页参数。tRPC 的 router 输入输出如果直接引用 Prisma 生成的类型,那么检查 SQL 返回结果类型的任务就完全交给了编译器。很多人一提 ORM 就担心性能,我的实测结论是:业务系统 95% 的查询都属于简单 CRUD,Prisma 的查询优化和连接池足够用;真正复杂到需要手写 SQL 的地方,完全可以在项目里开一条 prisma.$queryRaw 的旁路,不影响主流程。
TypeScript 和 Tailwind CSS 没什么好争论的,前者是 t3code 的基石语言,后者解决了“写样式时频繁切换上下文”的问题。尤其 Tailwind 的 JIT 编译模式,开发时几乎零配置,生产打包又能把无用样式自动移除。在我维护的多个项目里,Tailwind 的产物 CSS 始终稳定在 20KB 上下,这对性能是一个很重要的基础保障。
1.3 适合哪些场景,不建议哪些场景
先说不建议的场景,免得大家拿锤子找钉子。如果你是做一个高容错、强实时的服务端应用,比如长连接网关、消息队列处理系统,t3code 不一定合适,因为样板工程的数据库驱动和 API 风格都是为“请求-响应”模型设计的,套用到流式场景会别扭。如果你需要给第三方提供开放的 REST API 并长期维护多版本兼容,tRPC 这种调用模型也不是理想选择,因为 tRPC 的强类型协议外用时会变成一种负担。
反过来,它非常适合这几类场景:内部后台管理系统、中台业务工具、个人作品集网站、SaaS 产品的 MVP 版本。基本特征都是“团队规模不大、交付速度重要、数据模型不极端复杂、前后端都由同一批人维护”。在这样的场景里,t3code 的收益特别明显——你不需要写接口文档,因为类型就是文档;你不需要在前后端之间维护两套类型定义,因为只有一份。
2. 项目架构与目录结构设计拆解
2.1 整体目录结构与分层原则
t3code 没有采用 monorepo,就是一个 Next.js 应用,代码集中在src目录下。很多人觉得所有东西放一个应用里是“架构不良”,但我的观点是:架构简单性本身就是一种架构。项目初期强行拆包,只会增加发布流程和包版本管理的复杂度,技术在还没带来收益之前就已经消耗了你。
t3code/ ├── prisma/ │ ├── schema.prisma │ └── seed.ts ├── src/ │ ├── app/ │ │ ├── (auth)/ │ │ ├── (dashboard)/ │ │ ├── api/ │ │ ├── layout.tsx │ │ └── page.tsx │ ├── components/ │ │ ├── ui/ │ │ └── forms/ │ ├── server/ │ │ ├── api/ │ │ │ ├── routers/ │ │ │ └── root.ts │ │ ├── db/ │ │ └── auth/ │ ├── styles/ │ └── lib/ │ ├── validation/ │ └── utils.ts分层原则一句话:前端组件归components,后端逻辑归server,数据模型归prisma,验证规则归lib/validation。路由组(auth)和(dashboard)利用 Next.js 的 Route Group 特性,只用于组织布局,不产生 URL 路径,这样把“页面结构”和“权限区域”分开看,维护性提升很多。
这里特别说一下为什么要单拆一个server/api/routers而不是把所有 tRPC 路由写在一个文件里。当业务增长到几十个 procedure 的时候,单文件必然膨胀,而且多人协作容易冲突。t3code 的约定是:业务域一个文件,比如user.ts、post.ts、project.ts,最后在root.ts里用一个appRouter把它们合并。这种做法的好处是职责边界清楚,新加一个业务域不用动主文件。
2.2 类型安全链路的设计意图
t3code 类型安全的核心链条是:数据库表结构 -> Prisma Client 类型 -> tRPC Router 输入输出 -> React 组件 props。这条链上有三个关键枢纽,每个我展开说明一下。
第一个枢纽是 Prisma schema。拿用户模型举例:
model User { id String @id @default(cuid()) email String @unique name String? role UserRole @default(USER) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt posts Post[] } enum UserRole { USER ADMIN }schema 文件写完后,执行prisma generate,所有关于用户表和枚举的定义就变成了 TypeScript 里的User、UserRole类型。此后删除字段、修改类型都会在编译期暴露问题。
第二个枢纽是 tRPC 的 procedure。每一次接口定义都是对输入输出类型的一次显式约束:
import { initTRPC, TRPCError } from "@trpc/server"; import { z } from "zod"; const createUserSchema = z.object({ email: z.string().email(), name: z.string().min(1).max(50).optional(), }); export const userRouter = createTRPCRouter({ create: protectedProcedure .input(createUserSchema) .mutation(async ({ ctx, input }) => { const existing = await ctx.db.user.findUnique({ where: { email: input.email }, }); if (existing) { throw new TRPCError({ code: "CONFLICT", message: "该邮箱已注册", }); } return ctx.db.user.create({ data: input, }); }), });注意zod的 schema 既承担了运行时验证,又通过input方法的泛型推导让前端拿到了完整的入参类型。前端调用时,参数、返回值和错误类型全部自动提示,不需要查任何文档。
第三个枢纽是 React 组件里的数据流。t3code 的做法是把 tRPC 的 query 结果直接交给组件渲染,不在中间额外定义一个“DTO”层。很多人习惯在后端和组件之间再加一层数据映射,但在全栈不分离的场景下,这个映射层只会产生重复劳动。需要调整展示字段时,直接调整 query 里 select 的内容,组件那边同步变更即可。
3. 核心模块设计与实现要点
3.1 数据库模型设计:从需求到表结构的转化
数据库模型设计是很多样板工程写得模棱两可的地方,我在这里把 t3code 的实际做法写细一点。整体原则就两条:能用枚举不用字符串,能用关系不用冗余字段。
以用户-项目-标签这个经典场景为例,schema 设计的核心难点在多对多关系和统计字段上。我最初的版本是用户直接关联项目列表,项目关联标签数组,但后来发现列表页要展示“标签数量”和“项目成员数”时,查询要跨多张表做聚合,数据量一大就很吃力。后来改成在 Project 模型上增加_count的预聚合字段:
model Project { id String @id @default(cuid()) name String description String? ownerId String owner User @relation(fields: [ownerId], references: [id]) tags TagOnProject[] members ProjectMember[] createdAt DateTime @default(now()) @@index([ownerId]) }Prisma 的分页查询里可以直接用include: { _count: { select: { members: true, tags: true } } },不需要手写 count 子查询,前端列表页渲染非常快。这个技巧我在做管理后台时屡试不爽,建议数据分析不复杂的人都采用。
另外必踩的坑是枚举变更。Prisma 对枚举的修改迁移不是加字段,而是新增枚举值后重新迁移。如果在业务层用字符串常量来模拟枚举,数据库层面就管不住脏数据。t3code 的做法是:状态机字段一律用 Prisma enum,并且为每个枚举维护一份 zod enum 与之对应,形成双重校验。
3.2 tRPC 路由的组织与鉴权逻辑
鉴权是每个全栈应用都必须处理的问题,但处理不好就会变成到处飘着 if 判断的地毯式代码。t3code 的鉴权思路不是在每个 router 里写检查逻辑,而是通过 middleware 在 tRPC 层统一拦截。
先说登录态方案。项目使用 Auth.js(即 NextAuth)做会话管理,但不用它的默认getServerSession到处查一遍,而是在 tRPC 的 context 里解析一次:
export const createTRPCContext = async (opts: CreateNextContextOptions) => { const session = await getServerSession(authOptions); return { db: prisma, session, user: session?.user, }; };接着定义两组基础 procedure:
export const publicProcedure = t.procedure; export const protectedProcedure = t.procedure.use( async ({ ctx, next }) => { if (!ctx.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { user: ctx.user } }); } );在业务 router 里,公开信息和需要登录的接口分开标记,比在代码内部写 if 判断清晰得多。比如用户信息查询用publicProcedure,修改资料用protectedProcedure,团队管理接口再加一个adminProcedure去校验用户角色。
这里有一个细节值得注意:tRPC 的 middleware 是叠加执行的,你可以在adminProcedure里继续复用protectedProcedure的逻辑。不要把角色判断混进鉴权 middleware,否则后期加角色种类时要回去改所有接口。
3.3 前端数据加载模式:Server Component 与 tRPC 的配合
新版 Next.js 里,页面组件默认是 Server Component,很多人不知道怎么在服务端调用 tRPC。t3code 的推荐做法是:在服务端直接调用 tRPC 的 caller,而不是通过 HTTP 再请求自己一次,避免无谓的网络往返。
import { createCaller } from "@/server/api/root"; import { createTRPCContext } from "@/server/api/trpc"; export default async function DashboardPage() { const ctx = await createTRPCContext({}); const caller = createCaller(ctx); const stats = await caller.project.getStats(); return <DashboardView stats={stats} />; }这个模式的好处是类型安全依旧完整,但页面加载过程变成一个数据库查询,而不是“页面请求 -> API 请求 -> 数据库查询”的三级跳。对性能敏感的项目,这个差异非常明显。不过要注意:不要每次都手动创建 caller,最好用一个getServerCaller辅助函数统一封装,把 context 构建和异常处理都收拢到一处。
客户端交互部分,tRPC 的 React Query 集成帮了大忙。比如列表页的乐观更新:
const utils = api.useUtils(); const deleteProject = api.project.delete.useMutation({ onSuccess: () => { utils.project.list.invalidate(); }, });操作成功之后自动失效缓存并重新拉取列表,省掉一大片手工状态管理。如果担心列表刷新闪烁,可以配合placeholderData: keepPreviousData让渲染平滑过渡。
4. 部署准备与配置细节
4.1 环境变量与敏感信息管理
部署阶段的坑往往不是代码问题,而是配置问题。t3code 给出一份环境变量模板,见下表:
| 变量名 | 用途 | 示例 |
|---|---|---|
| DATABASE_URL | Prisma 连接字符串 | postgresql://user:pass@host:5432/db |
| NEXTAUTH_SECRET | 会话加密密钥 | openssl rand -base64 32 生成 |
| NEXTAUTH_URL | 登录后会跳转的站点基础地址 | https://your-domain.com |
| NEXT_PUBLIC_APP_URL | 前端资源加载的公开地址 | https://your-domain.com |
特别注意 NEXTAUTH_SECRET 不要提交进仓库,也不要用一个固定的测试字符串上线。实践建议是:本地放.env,生产环境由部署平台注入变量,仓库只保留.env.example占位。.env文件必须写进.gitignore,这是最低要求。
还有一个容易忽略的配置是 Next.js 的 standalone 输出。在next.config.ts里设置output: "standalone",构建产物会自动包含服务器运行所需的最小文件集,搭配 Docker 镜像时体积能减少一多半。我实测过,普通 Next.js 应用镜像可以做到 150MB 以内,配合 node 环境部署,启动速度远高于完整依赖镜像。
4.2 构建部署与性能优化
构建前必做的一件事是执行prisma migrate deploy而不是prisma migrate dev。dev 会触发交互式提示和随机数据打标,只能用于开发环境;deploy 则是静默地把迁移应用到目标数据库,适合 CI/CD。顺序上推荐先跑数据库迁移,再构建 Next.js 应用,否则应用启动时如果 schema 和数据库不一致,查询会直接白屏。
生产构建走常规流程即可:
pnpm install --frozen-lockfile pnpm prisma generate pnpm prisma migrate deploy pnpm build pnpm startt3code 里默认开启了 Next.js 的内置优化,但有些坑需要自己处理。一是图片组件next/image如果引用了外部图片源,必须在next.config.ts里配置remotePatterns,否则生产环境会拒绝加载。二是所有非首屏组件统一使用dynamic导入并按需加载,不要担心代码分割影响 SEO,Next.js 是流式渲染,非关键资源不会阻塞首屏。
5. 常见问题与排查技巧实录
5.1 必踩的坑和对应解决方案
t3code 从头到尾开发下来,我踩过的坑能列一长串,挑几个最有代表价值的写在这里。
第一个是 tRPC 的 Context 重复实例化问题。早期我在每个页面组件里直接调用createTRPCContext,导致每个请求创建了多个 PrismaClient 实例,数据库连接数飙升。后来把 PrismaClient 放在全局单例里才解决。这个坑的根本原因是对 Next.js 的开发模式热更新机制不熟悉,每次模块更新都会重新执行依赖初始化。解法是在lib/db.ts里判断全局缓存:
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }; export const prisma = globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV !== "production") { globalForPrisma.prisma = prisma; }第二个是 zod 版本兼容问题。tRPC v10 和 zod v3 是配套的,如果升级到 zod v4,很容易出现类型推导中断的报错。不用急着升级,项目稳定运行的依赖组合不要为了新而新去动它。第三方库的版本矩阵,在样板工程里比在业务代码里更敏感。
第三个坑是 RSC 组件中调用 tRPC 时的 catch 处理。tRPC 抛出的 TRPCError 在服务端不会自动转成 HTTP 错误,而是作为异常向上抛。如果不在全局错误边界接住,页面会直接渲染错误状态。t3code 的做法是在 root layout 里加一个error.tsx边界,统一给用户展示可读信息。
再来一个表单验证的经典问题:很多人只在前端用 zod 做了验证,后端完全不设防。这在 t3code 里不存在,因为 tRPC 的 input 本来就是强校验,没有绕过它的路径。但要注意的是,不要把createUserSchema这种入口校验 schema 直接复用在更新接口上,否则会出现“可选字段无法置空”的语义问题,这是被问到很多次的细节。
5.2 排查路上的实用命令
平时排查问题时,以下几个命令和操作能帮你快速定位,我整理成一个速查表。
| 症状 | 排查命令 | 补充说明 |
|---|---|---|
| 页面提示类型不匹配 | pnpm tsc --noEmit | 检查全项目类型错误,重点看 tRPC 返回类型报错 |
| 数据库结构不同步 | pnpm prisma migrate status | 检查迁移文件与数据库的偏差,deploy 前必跑 |
| 登录状态丢失 | pnpm auth:debug | 检查会话 cookie 的签名与有效期 |
| 样式错乱 | pnpm build && pnpm start | 开发模式样式正常但生产异常时优先跑生产构建 |
| 数据库连接数过高 | psql -c "select count(*) from pg_stat_activity" | 排查是否出现 PrismaClient 重复实例化 |
还有一个排查技巧:如果 tRPC 接口在后端抛错但前端返回的是 500 而非具体错误码,看一下 Next.js 的NEXT_PUBLIC_APP_URL是否配置正确。很多部署平台的跨域问题其实都是环境变量没配好。
5.3 提速技巧:从开发到交付的细节优化
最后分享几个我在 t3code 的迭代中觉得收益很高的优化技巧。
服务端数据缓存要善用 Next.js 的unstable_cache进行细粒度控制。统计数字、配置项这类不常变的数据,可以用它把数据库压力挡掉一部分。当然缓存一定要设置合理的revalidate时间,否则数据更新不及时反而容易引发线上反馈问题。
开发的另一个提速点是 Tailwind 的类名组织和组件抽取边界。我建议把可复用的表单输入、弹窗、分页器都抽到components/ui目录,但不要为了抽象而抽象。少于两处使用的地方先不抽,等技术债积累到明显痛点再重构,这个节奏对中小项目最友好。
还有一件事很值得投入:把 Prisma 的seed.ts做成可重复执行的数据工厂。每次新项目拉下来,一根命令生成演示环境的数据,能让联调和 UI 开发节省大量时间。t3code 的 seed 脚本会对每个业务域生成固定数量与关联关系的数据,比如 10 个用户、每个用户带 5 个项目、每个项目带若干标签。
我个人实际使用下来的体会是,样板工程这种东西,最大的价值不是那一堆配置代码,而是它把“正确决策”沉淀下来。别人踩过的坑你不用再踩,技术选型的权衡过程不用重复论证。t3code 的目标也就是把这个过程固化下来,无论你自己用还是拿去给团队做内部脚手架,都能少走弯路。如果你正在纠结新项目怎么起步,不妨把这份结构拿过去跑一遍,遇到具体问题再去调整,你会发现先解决环境问题,再谈业务实现,会顺畅得多。最后再分享一个小技巧,任何样板工程维护到一定阶段,都要记得定期升级依赖并跑一遍全量测试,这样才能保证“拉下来就能跑”不是一句空话。