t3code:基于T3 Stack的全栈模板,实现端到端类型安全

发布时间:2026/10/26 8:42:11
t3code:基于T3 Stack的全栈模板,实现端到端类型安全 T3这个代号在开发者圈子里已经被说烂了但真正能落地的其实不多。最近我把自己的全栈模板项目彻底重构成了 t3code——一个以 T3 Stack 为骨架、以端到端类型安全为核心诉求的实战模板。如果你正打算从零搭一个全栈应用受够了 API 层类型断裂、前后端字段对不上、改个数据模型要同步改三处类型的日子那 t3code 这套方案很值得你花十分钟看完。它不是一个库也不是一个框架而是一整套已经被验证过的项目组织和工程化思路你完全可以把它抄到自己的项目里。这篇文章会把我重构过程中的设计取舍、核心代码实现、以及踩过的坑一次性讲清楚保证不是那种只贴几个链接的搬运文。1. 整体设计思路t3code 到底在解决什么问题1.1 三个核心原则如何落到项目里t3code 这个名字里的 T3很多人以为是三个首字母 T 的缩写其实它代表的是三条非常明确的设计原则类型安全优先。从数据库表结构到 API 响应再到前端组件的 props全程用 TypeScript 的类型推导串起来不让任何一层的数据“失去类型”。务实主义。不为了炫技引入高复杂度的框架。能用简单机制解决的绝不上重武器。这一点体现在后面选型上比如 API 层不用 REST 也不用 GraphQL而是用 tRPC。规模化简约。项目结构一开始就要为后续半年一年的迭代负责。新增一个业务模型时理想情况是只碰数据库 schema 和路由文件两个地方其他层自动推导。这三条原则实际落地后最直观的感受是我几乎不再需要打开网络面板去看接口返回了什么字段。前端代码能自动补全出来参数错了编译器直接报错这比任何测试用例都来得直接。1.2 技术栈选型对比为什么不是 Next.js Express GraphQL我早期做全栈项目用的是“Next.js Express GraphQL”组合后来转向 tRPC 之后对比非常明显。对比维度Next.js Express GraphQLt3code 方案Next.js tRPC类型安全链路GraphQL 有 schema但前端生成类型要额外跑 codegentRPC 直接从路由函数推导类型零 codegen服务端接口定义要写 resolver、type、query、mutation 四件套一个 router 文件搞定procedure 即接口学习曲线GraphQL 概念多缓存、嵌套解析器都是坑函数式调用跟写普通异步函数没区别前后端联调成本需要维护 introspection 和 fragment类型自动推导字段名拼错直接编译报错适合的项目规模中大型团队多客户端消费同一 API中小团队、个人全栈项目追求效率如果你只有一两个客户端Web 和移动端tRPC 是效率之王。GraphQL 的价值在于多客户端、多团队协作时的契约管理但为此付出的复杂度是实打实的。t3code 选择 tRPC核心逻辑就是砍掉一切不必要的间接层。SSR 和 RSC 场景下tRPC 还能直接复用服务端的函数调用不需要走 HTTP。1.3 项目目录结构设计t3code 的目录结构经过多轮重构当前版本长这样src/ ├── app/ # Next.js App Router 路由 │ ├── api/auth/ # NextAuth 回调 │ ├── (auth)/ # 登录/注册页面组 │ ├── (main)/ # 主业务页面组 │ └── layout.tsx ├── components/ # UI 组件按功能分目录 │ ├── ui/ # 基础组件 │ ├── todo/ # 业务组件 │ └── settings/ ├── server/ │ ├── api/ │ │ ├── routers/ # tRPC 路由 │ │ ├── root.ts # 路由聚合 │ │ ├── trpc.ts # tRPC 初始化 │ │ └── context.ts # 请求上下文包含 session │ ├── auth.ts # NextAuth 配置 │ └── db.ts # Prisma Client 单例 ├── trpc/ │ ├── client.ts # 客户端 tRPC 实例 │ ├── server.ts # 服务端 tRPC 实例RSC 用 │ └── react.tsx # QueryProvider 封装 ├── env.js # 环境变量校验 ├── lib/ # 业务无关工具 └── types/ # 全局类型按需补充这套结构遵循一个原则服务端能力全部收敛在 server 目录前端只通过 trpc 目录暴露的 hooks 消费。这样即使以后换掉 Next.js业务逻辑层依然可以平移到别的框架不会被 App Router 的细节绑架。2. 从零搭建 t3code 的完整流程2.1 初始化项目选对交互选项t3code 的基础脚手架直接用了 create-t3-app 生成的模板然后再按自己的需求裁剪。初始化命令npm create t3-applatest交互式选项里我建议全选核心库NextAuth、Prisma、Tailwind、tRPC。这一步的结果是生成一个开箱即用的全栈骨架。如果你已经有现成项目也可以手动加入 tRPC但工作量会大不少create-t3-app 帮你把 context、路由初始化这些样板代码都处理好了。初始化完成后第一件事是把目录结构里的 examples 删掉EAS 示例、恶意分享示例只保留一个干净的待办模块做演示。这不是闲得慌而是为了让接手项目的人不被样例代码干扰。2.2 环境变量与配置解析t3code 使用了src/env.js做启动时环境变量校验这属于“早失败”策略。与其在运行时突然报错不如在开发服务器启动时就暴露问题。# .env DATABASE_URLpostgresql://postgres:passwordlocalhost:5432/t3code NEXTAUTH_SECRET生成一个足够长的随机字符串 NEXTAUTH_URLhttp://localhost:3000如果你的环境变量在部署平台上是运行时注入的那么env.js里的客户端白名单就格外重要。NEXT_PUBLIC_前缀的变量会打包进浏览器代码其他变量一律只在服务端可读。2.3 数据库模型设计t3code 的数据库模型以 Todo 应用作为示例核心是演示“用户与数据”的关系。以下是精简后的 Prisma Schema// prisma/schema.prisma generator client { provider prisma-client-js } datasource db { provider postgresql url env(DATABASE_URL) } model User { id String id default(cuid()) name String? email String? unique emailVerified DateTime? image String? accounts Account[] sessions Session[] todos Todo[] } model Account { id String id default(cuid()) userId String type String provider String providerAccountId String refresh_token String? access_token String? expires_at Int? token_type String? scope String? id_token String? session_state String? user User relation(fields: [userId], references: [id], onDelete: Cascade) unique([provider, providerAccountId]) } model Session { sessionToken String unique userId String expires DateTime user User relation(fields: [userId], references: [id], onDelete: Cascade) } model Todo { id String id default(cuid()) title String completed Boolean default(false) createdAt DateTime default(now()) userId String user User relation(fields: [userId], references: [id], onDelete: Cascade) }IDE 里写 Prisma Schema 时我强烈建议开启 Prisma 官方插件能提示字段引用和 relation 错误省掉很多npx prisma format来回折腾的时间。生成客户端npx prisma migrate dev --name init npx prisma generate有一点容易忽略migrate dev 会自动触发 generate但如果你在多环境工作比如别的同事拉取代码仍然要单独跑一次 generate确保本地 node_modules 里的客户端与 schema 同步。3. 核心功能实现tRPC 端到端类型安全的实际落地3.1 服务端路由定义与校验逻辑t3code 的 API 层完全由 tRPC router 承载。以 Todo 模块为例// src/server/api/routers/todo.ts import { z } from zod; import { createTRPCRouter, protectedProcedure } from ../trpc; export const todoRouter createTRPCRouter({ getAll: protectedProcedure.query(async ({ ctx }) { return ctx.db.todo.findMany({ where: { userId: ctx.session.user.id }, orderBy: { createdAt: desc }, }); }), create: protectedProcedure .input(z.object({ title: z.string().min(1).max(100) })) .mutation(async ({ ctx, input }) { return ctx.db.todo.create({ data: { title: input.title, userId: ctx.session.user.id, }, }); }), toggle: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) { const todo await ctx.db.todo.findUnique({ where: { id: input.id }, }); if (!todo || todo.userId ! ctx.session.user.id) { throw new Error(Todo not found or not owned by user); } return ctx.db.todo.update({ where: { id: input.id }, data: { completed: !todo.completed }, }); }), delete: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) { await ctx.db.todo.deleteMany({ where: { id: input.id, userId: ctx.session.user.id }, }); return { success: true }; }), });这里有几个细节protectedProcedure保证了没有登录的用户直接收到未授权错误不会进入数据库查询逻辑。deleteMany配合 userId 条件是防止越权操作的关键。很多新手直接delete结果任意登录用户都能删别人的 Todo。zod 校验在 tRPC 中不只是为了校验更重要的是给前端推导出输入类型。调用方不传title或传空字符串类型系统直接报错。3.2 前端调用类型推导的实际体验前端消费上述接口的方式非常直观// src/components/todo/todo-list.tsx use client; import { api } from ~/trpc/react; export function TodoList() { const { data: todos, isLoading } api.todo.getAll.useQuery(); const utils api.useUtils(); const createMutation api.todo.create.useMutation({ onSuccess: async () { await utils.todo.getAll.invalidate(); }, }); const toggleMutation api.todo.toggle.useMutation({ onSuccess: async () { await utils.todo.getAll.invalidate(); }, }); if (isLoading) return p加载中.../p; return ( ul {todos?.map((todo) ( li key{todo.id} label input typecheckbox checked{todo.completed} onChange{() toggleMutation.mutate({ id: todo.id })} / {todo.title} /label /li ))} /ul ); }这段代码能直接从api.todo.getAll自动补全返回类型todos数组里每个元素的id和title都是确定类型。输错字段名直接编译失败比任何运行时兜底都靠谱。这里要说一个关键点为什么 mutation 成功后要调用invalidate这不是 t3code 特有的而是 TanStack Query 的核心机制。查询结果会被缓存如果不想看到旧数据必须在写操作成功后刷新对应查询的缓存。我实际生产环境中还加过一个技巧如果create接口恰好返回了新创建的 Todo 列表可以直接用setData做乐观更新连invalidate都不用性能更优。3.3 认证与会话protectedProcedure 背后的机制t3code 使用 NextAuth v4在src/server/api/context.ts中将 session 注入 tRPC context// src/server/api/context.ts import { getServerAuthSession } from ~/server/auth; import { db } from ~/server/db; export const createTRPCContext async (opts: { headers: Headers }) { const session await getServerAuthSession(); return { db, session, headers: opts.headers, }; };然后protectedProcedure的定义// src/server/api/trpc.ts export const protectedProcedure t.procedure.use(({ ctx, next }) { if (!ctx.session?.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { session: { ...ctx.session, user: ctx.session.user }, }, }); });这样每个需要登录的接口服务端能直接拿到ctx.session.user.id无需每个路由自己去解析 cookie 或 token。这是全栈项目中服务端鉴权的正确姿势。我第一次跑通这个流程时最惊讶的是我在浏览器里登录然后从 DevTools 拷贝请求头到 Postman 调试发现 missing token 报错这提醒我 session 挂在 httpOnly cookie 而非 Authorization Header任何手动构造的请求都需要模拟完整 cookie 串这就是 NextAuth 与纯 token 方案的一个区别。4. 部署与生产环境实战4.1 构建优化与产物分析t3code 使用 Next.js App Router Server Components核心优化点在于让尽量多的组件保持服务端渲染只有需要交互的叶子节点用use client。这样首屏 HTML 直接带数据搜索引擎和弱网用户都能拿到内容。生产构建时我习惯跑一遍npm run build npx next/bundle-analyzer检查最大的 chunk 是否来自 tRPC 或 Prisma 的意外打包。实测中如果某个页面意外引入了服务端 DB 代码客户端包体积会瞬间飙升几百 KB。一个简单的判断方法任何直接 importprisma/client的文件都不能被客户端组件引用否则 Prisma 引擎会被打包进浏览器。t3code 模板里我会把日志输出打在关键位置比如 tRPC router 的输入、错误捕获、查询耗时。生产环境下用 pino 结构化日志开发环境用 console 就行避免过早引入日志治理复杂度。4.2 部署到 Vercel 的注意细节部署前先去 Vercel 的项目设置里配好环境变量。最常踩的坑是变量典型错误正确做法NEXTAUTH_URL开发环境的http://localhost:3000忘记改设置为生产域名的完整 URLNEXTAUTH_SECRET使用固定字符串如secret用openssl rand -base64 32生成DATABASE_URL使用本地 PostgreSQL 连接串生产环境必须用云端数据库如 Neon、Supabase的连接串数据库连接池直接使用 serverless 不支持的单连接考虑 Prisma Accelerate 或连接池方案Vercel 的 Serverless 环境里每个请求可能由独立 Node.js 实例处理如果交给 Prisma Client 默认的连接管理很容易把数据库连接数打满。我建议在生产环境显式用连接池 URL并且给 Prisma Client 加connection_limit参数// src/server/db.ts import { PrismaClient } from prisma/client; const globalForPrisma globalThis as unknown as { prisma?: PrismaClient }; export const db globalForPrisma.prisma ?? new PrismaClient({ log: process.env.NODE_ENV development ? [query, error, warn] : [error], }); if (process.env.NODE_ENV ! production) { globalForPrisma.prisma db; }这段代码的核心是global 单例避免开发环境下热更新导致多个 PrismaClient 实例。如果你在开发时发现“Already 10 Prisma Clients are running”这样的警告就是这个原因。4.3 静态渲染与动态路由t3code 里凡是需要读 session 的页面必须标记为动态渲染否则构建时会尝试静态生成却发现没有请求上下文。Next.js App Router 下可以这样// src/app/(main)/dashboard/page.tsx export const dynamic force-dynamic;这个设置避免了一个我踩过的坑部署后发现用户的登录态时有时无一会儿有数据一会儿没数据看着像是数据库不稳定其实是构建产物被静态化了。5. 常见问题与排查技巧实录5.1 NextAuth 集成 Prisma 的经典报错报错信息PrismaClientInitializationError: Prisma Client could not initialize排查步骤确认DATABASE_URL已正确设置且数据库可连接。执行npx prisma generate确保客户端代码与 schema 同步。如果用了 NextAuth 的 PrismaAdapter检查是否正确传入了PrismaClient实例import { PrismaAdapter } from next-auth/prisma-adapter; import { db } from ~/server/db; export const authOptions { adapter: PrismaAdapter(db), session: { strategy: jwt }, // 生产环境下建议用 JWT避免依赖 Session 表 ... };这里重点说下session.strategy。默认情况下 NextAuth 使用数据库 Session需要在数据库里建 Session 和 Account 两张表而改成jwt后登录态全部塞进 token数据库只存用户表即可。对于 serverless 部署JWT 策略能减少一次数据库查询并且避免 Session 过期逻辑。代价是主动踢人下线的能力变弱业务上有强安全要求的场景需要谨慎。5.2 tRPC 类型不更新的问题开发过程中我经常遇到一种情况修改了 router 的输入输出类型前端代码里类型提示依然是旧的。这不是 tRPC 的问题而是 TypeScript 语言服务缓存滞后。有效解法在项目中停用或重启用 TS 服务VS Code 命令面板TypeScript: Restart TS Server。如果还不行删掉.next、node_modules/.cache后重启 dev server。检查tsconfig.json是否设置strict: true关闭严格模式会损失大量类型检查能力等于放弃了 tRPC 一半价值。5.3 数据库连接泄漏的排查思路现象本地跑得好好的上生产跑几小时开始出现TimeoutError: Resource acquisition timed out思路先看数据库连接数是否达到上限。检查是否每个请求都新建了 PrismaClient。看日志里是否有连接未释放的迹象长时间空闲连接数持续上涨。解决建议按上文src/server/db.ts的做法全局复用一个 PrismaClient。如果并发量确实高再考虑给DATABASE_URL加连接池参数DATABASE_URLpostgresql://user:passhost:5432/db?connection_limit10pool_timeout10实测中把连接池参数从默认 10 调成connection_limit5后很多小项目的数据库连接压力立竿见影地降下来。5.4 前端环境下误用服务端代码新手最容易犯的错use client组件中 import 了~/server/db结果浏览器控制台直接报 Prisma 无法初始化。严格约定任何 import 了prisma/client、~/server/*、process.env.DATABASE_URL的模块只能在服务端组件或 API 层使用。客户端组件通过~/trpc/react的 hooks 获取数据不要直接触碰服务端模块路径。借助 ESLint 的no-restricted-imports规则禁止客户端目录 import 服务端路径这是一个很实用的纪律。6. 写在最后t3code 这套方案的维护成本与取舍实话说t3code 不是银弹。它最适配的场景是中小型全栈项目、个人作品、内部工具、创业 MVP。团队已经有成熟微服务架构的硬套这套会影响边界。但如果你像我一样希望用一个晚上就搭出前后端类型完全一致的工程t3code 的成本收益比是我见过最漂亮的。我个人在后续迭代中受益最深的一点是数据库改表之后重启 dev server 立即能感受到前端代码跟着变。新增一个字段所有用到该字段的地方自动出现在补全列表里不用跑任何生成命令。这种感觉远超我之前用 REST 和 GraphQL 的体验。最后再分享一个细节技巧t3code 里所有的 mutation 都保持了相对小的粒度create、toggle、delete各自独立而不是做一个大的updateTodo让前端传一堆字段。这样做的原因是 tRPC 路由天然有类型约束需要传什么、能传什么前端代码里一目了然不至于为了复用把输入类型搞成 Optional 满天飞。如果你正在纠结技术栈选型或者对端到端类型安全的实践感兴趣照着 t3code 这套流程跑一遍用不用 tRPC 不重要关键是体会一下“类型从数据库一路流到浏览器”到底是什么感受。那种编译器帮你兜底的安全感一旦体验过就回不去了。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询