
先解释下 t3code 是什么。它不是一个开源框架也不是某个现成的工具而是我最近在做一个内部全栈项目时给代码仓库起的代号。项目的技术底座是 The T3 Stack也就是 Next.js、TypeScript、Tailwind CSS、tRPC、Prisma、NextAuth 这套组合实际实现包含用户登录、内容发布、列表分页、数据统计这些常见业务模块。t3code 这个名字的意思是 T3 Stack 的代码实践我做这个项目的目标就是验证一件事前后端类型完全打通之后开发一个中等复杂度的产品到底能不能省下大量联调和维护成本。如果你平时写 React还在用 REST 接口和手写类型定义或者刚接触全栈、不知道从哪下手这篇内容可以当成一份能直接照着操作的作业。我会从选型思路、目录结构、数据库设计、权限控制讲到部署和踩坑尽量把每一个选择背后的原因也讲清楚。项目不大但足够覆盖一个真实业务系统的常见问题。1. t3code 项目整体设计与思路拆解1.1 为什么把全栈默认配置成 T3 Stack做 t3code 之前我也用过不少方案前端用 React 或 Vue后端用 Express 或 Fastify数据库用 MySQL 或 PostgreSQL。最让人难受的不是写代码而是接口联调。前端定义User类型后端也定义一份User类型两边一有改动全靠文档和聊天工具同步。时间一长接口文档过时了类型对不上了后端返回了一个新字段前端运行到某个角落才报undefined。T3 Stack 解决的正是这个问题。它不是一个强绑定的框架而是一组经过验证的工具组合核心理念是让类型安全贯穿应用每一层。Next.js 负责前端页面和 API 宿主TypeScript 负责静态类型tRPC 让前端可以直接调用后端函数并获得完整类型Prisma 把数据库表结构变成 TypeScript 类型NextAuth 处理登录态。这套组合把过去需要单独维护的类型、路由、校验、迁移工具统一到了一起。当时给 t3code 做技术选型时还有同事问我怎么不用 GraphQL。GraphQL 同样有类型生成但它的学习曲线和运行时复杂度都更高。t3code 是内部业务系统没有大量跨端调用方也不需要把接口开放给第三方用 tRPC 这种给同构应用用的 RPC反而简单直接。T3 Stack 的核心原则是 simple, focused, no over-abstraction翻译过来就是只做必要封装不为未来可能不存在的问题提前设计。1.2 六个核心技术选型是怎么定下来的t3code 的每一层都经历了对比。做选择的标准只有一条能不能减少心智负担让开发者把注意力留在业务上。技术在 t3code 中的角色选型理由Next.js 14应用框架同时承担页面渲染和 API 逻辑App Router 原生支持服务端组件TypeScript静态类型全程严格模式所有跨文件数据都由类型约束Tailwind CSS样式方案原子类让样式直接服务于结构不需要再维护 CSS 文件tRPC接口层前后端函数调用零中间层类型自动推导PrismaORM 和迁移schema 直观迁移命令可靠生成类型和编辑器提示流畅NextAuth认证自带多平台登录和 tRPC middleware 配合做登录校验这些技术不建议随意替换。比如有人问能不能把 Prisma 换成 TypeORM把 tRPC 换成 REST。当然可以但你要接受类型断层。t3code 最大的价值恰恰是链路完整数据库 schema 一改跑一次生成命令前端调用方的类型就跟着变。只要有一层是普通的 HTTP 手写类型这条类型自动流动的线就断了。我也没有选 Vue 或 Svelte 这条路线。T3 Stack 里的 T 虽然是 TypeScript 的意思但整个生态基于 React 和 Next.js 最成熟。选择一个生态更稳的底座对后续招人和维护更有利。1.3 t3code 适合解决哪些场景问题做完这个项目我把它总结成三个典型适用场景。第一个是内部工具和后台系统。这类系统通常字段多、流程短、权限明确最大的痛点是迭代快、接口文档永远跟不上代码。t3code 里改字段只动 schema 和 router前端调用处会立刻报错反而变成一个可预期的提示。第二个是内容管理类产品。文章、评论、点赞这些数据关系用 Prisma 建模非常顺手tRPC 的 query 和 mutation 也能很自然地映射到读和写。我在 t3code 里做了用户发布短文和列表展示整个链路写得很顺手。第三个是快速验证产品想法。以前做一个全栈 demo 要同时开几个服务现在pnpm dev一个命令全部搞定改了代码保存即刷新。类型推导省去翻文档的时间对个人开发者和 3-5 人的小团队都很友好。它不太适合的场景是有大量第三方调用、需要对外提供 SDK 或 HTTP API 的开放平台以及多团队语言异构的微服务系统。那种场景用 tRPC 反而会把系统边界搞模糊REST 或 gRPC 才是更清晰的接口契约。2. t3code 核心细节解析与实操要点2.1 目录结构一条从数据库到页面的直线用 create-t3-app 初始化项目后默认目录已经帮你划分好了职责。我在 t3code 中保持了原生结构没有做过度定制因为默认结构已经足够清晰t3code ├── prisma │ └── schema.prisma ├── src │ ├── app │ │ ├── api │ │ │ ├── auth │ │ │ └── trpc │ │ ├── posts │ │ ├── layout.tsx │ │ └── page.tsx │ ├── server │ │ ├── api │ │ │ ├── routers │ │ │ └── root.ts │ │ ├── auth.ts │ │ └── db.ts │ ├── trpc │ │ ├── react.tsx │ │ └── server.ts │ └── env.mjs └── .env重点看src/server和src/trpc。src/server里放所有只在服务端运行的代码比如数据库实例db.ts、认证配置auth.ts、tRPC router 定义src/trpc里放服务端和客户端共用的类型与上下文。Next.js 的 App Router 会严格区分客户端和服务端凡是 import 了server/db的文件默认只能出现在服务端组件里。类型流向是理解整个项目的核心。Prisma schema 先定义数据库结构运行时生成PrismaClient类型tRPC router 的查询函数返回的是 Prisma 的实体类型src/trpc/react.tsx会把这些类型暴露成一组 React hooks。前端useQuery拿到的结果天然带有数据库字段的类型不用再单独声明一遍。这个链路是单向的排查问题的时候沿着页面 → router → schema 往下找就行不会出现前端一个类型、后端一个类型对不上号的情况。2.2 数据库模型与枚举设计里的细节t3code 里最核心的数据模型是 User 和 Post我把它们设计得尽量贴近真实业务。下面这段 schema 是简化版但保留了关键设计enum Role { USER ADMIN } model User { id String id default(cuid()) name String? email String? unique role Role default(USER) posts Post[] createdAt DateTime default(now()) updatedAt DateTime updatedAt } model Post { id String id default(cuid()) title String content String authorId String author User relation(fields: [authorId], references: [id], onDelete: Cascade) createdAt DateTime default(now()) updatedAt DateTime updatedAt index([authorId, createdAt]) }主键用cuid()而不是自增 id。自增 id 虽然好看但在分布式部署和生产环境里容易暴露业务规模迁移到分库分表时也会很麻烦。如果你只是本地 demo自增没问题要上生产建议一开始就用 cuid 或 uuid 这类全局唯一主键。外键关联必须写onDelete。我见过不少同事在 Prisma 里只写author User relation删除用户时直接报外键约束错误。t3code 里我用了Cascade这样删掉用户会自动删掉他的所有文章避免数据库里残留无人认领的帖子。如果你的业务需要保留用户历史内容可以改成Restrict或SetNull关键是要在模型层就明确行为不要等运行时才发现。index([authorId, createdAt])看着不起眼但对列表查询影响很大。Post 页面最常见的操作是按作者分页查文章如果没有这个联合索引数据量上来后查询会变成全表扫描。建联合索引的字段顺序也有讲究等值字段authorId放前面排序字段createdAt放后面比反过来的效率高很多。2.3 认证、会话与权限的前后端贯通认证这块要做的事有两件让用户登录以及在后端接口里校验身份。NextAuth 处理第一件事tRPC middleware 处理第二件事。我在 t3code 里同时配置了 GitHub 登录和 Credentials 登录。GitHub 登录方便线上演示Credentials 登录方便离线测试。NextAuth 的回调函数里会在 session 对象中暴露user.id这一步很关键因为数据库里操作数据时需要拿到当前用户 id。tRPC 侧我在createContext里把session注入进去然后定义一个protectedProcedureconst protectedProcedure t.procedure.use(({ ctx, next }) { if (!ctx.session?.user) { throw new TRPCError({ code: UNAUTHORIZED, message: 请先登录, }); } return next({ ctx: { ...ctx, user: ctx.session.user, }, }); });之后所有需要登录态的接口都改用它比如protectedProcedure.query(...)和protectedProcedure.mutation(...)。这样权限判断只写一次不用在每个接口里重复if (!ctx.session)。业务逻辑里只要是ctx.user就是确定存在的类型上也安全。权限控制不要一上来做很重。我的经验是先做两层登录/未登录以及本人/非本人。管理后台加角色时再在 Procedure 里加一个role判断中间件。做太复杂容易把自己绕进去尤其是早期需求不明时ABAC、RBAC 这类概念往往用不上。2.4 样式隔离与组件规范Tailwind 最大的优点是快最大的缺点是 JSX 里类名会越来越长。为了不让样式失控我在 t3code 里封装了一个小工具函数import { clsx, type ClassValue } from clsx; import { twMerge } from tailwind-merge; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }clsx负责合并条件类名tailwind-merge负责处理同类 Tailwind 工具的优先级。比如一个组件同时传了px-2和px-4twMerge会自动保留后一个不会出现两个类同时存在但最终效果不确定的问题。这个函数长得很普通但实际维护时省了非常多心思。组件规范上我坚持每条业务组件只负责一种职责样式类统一从 props 或内部常量读取。不要在组件里使用动态拼接类名比如text-${size}-xl因为 Tailwind 的 JIT 编译器需要看到完整类名才能生成对应样式。动态拼接出来的类在构建时会被清理掉页面就丢了样式。正确做法是维护一个映射表把size值映射到完整的类名上。2.5 环境变量管理与安全底线t3code 用了env.mjs而不是直接在代码里读process.env。这个文件会在应用启动时校验所有环境变量缺失或格式不对直接报错避免等运行到一半才发现数据库连接串是 undefined。import { createEnv } from t3-oss/env-nextjs; import { z } from zod; export const env createEnv({ server: { DATABASE_URL: z.string().min(1), NEXTAUTH_SECRET: z.string().min(32), GITHUB_CLIENT_ID: z.string().optional(), GITHUB_CLIENT_SECRET: z.string().optional(), }, client: { NEXT_PUBLIC_APP_URL: z.string().url().optional(), }, runtimeEnv: { DATABASE_URL: process.env.DATABASE_URL, NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET, GITHUB_CLIENT_ID: process.env.GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET: process.env.GITHUB_CLIENT_SECRET, NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL, }, });这里有一个安全底线所有带密钥的变量只放在server里client里只放开以NEXT_PUBLIC_开头的公开变量。Next.js 会把NEXT_PUBLIC_开头的变量编译进浏览器端打包产物所以任何不该让用户看到的密钥都不能用这个前缀。3. 实操过程与核心环节实现3.1 一行命令创建 t3code 项目初始化项目我用的是 create-t3-app。先确保本地有 pnpm然后执行pnpm create t3-applatest t3code交互式向导里可以勾选要用的功能我选了 Next.js、Tailwind CSS、tRPC、Prisma、NextAuth没有勾选 ESLint 和 Prettier 之外的额外插件尽量保持干净。向导结束后复制.env.example为.env.local先填DATABASE_URL。本地开发我用 SQLite配置是file:./db.sqlite后面测试通过再换 MySQL 或 PostgreSQL。启动前还差一步数据库初始化pnpm prisma db push这个命令会按照schema.prisma直接创建数据库表适合开发环境。如果是生产环境应该用pnpm prisma migrate deploy执行一遍迁移记录避免表结构漂移。启动开发服务器pnpm dev打开http://localhost:3000/api/trpc/health能看到健康检查返回成功整套链路就通了。如果这一关过不了后面写再多代码都会卡在奇怪的地方所以首次启动值得多花五分钟确认环境。3.2 发布短文功能从数据库到页面的完整闭环t3code 的第一个业务功能是发布短文。我挑这个功能是因为它覆盖了写、读、列表刷新三个最典型的场景。第一步是加模型。在schema.prisma中加入上面 Post 模型的代码然后执行pnpm prisma migrate dev --name init_postmigrate dev会生成一个 SQL 迁移文件并且同步更新 Prisma Client 的类型。如果之后发现有字段写错不要在原有迁移文件上修改新开一个迁移去改保持迁移历史线性。第二步是写 tRPC router。我在src/server/api/routers/post.ts里定义postRouter包含list和create两个方法import { z } from zod; import { createTRPCRouter, protectedProcedure } from /server/api/trpc; export const postRouter createTRPCRouter({ list: protectedProcedure.query(async ({ ctx }) { return ctx.db.post.findMany({ orderBy: { createdAt: desc }, include: { author: { select: { name: true } }, }, }); }), create: protectedProcedure .input( z.object({ title: z.string().min(1).max(100), content: z.string().min(1).max(5000), }) ) .mutation(async ({ ctx, input }) { return ctx.db.post.create({ data: { title: input.title, content: input.content, authorId: ctx.user.id, }, }); }), });注意input用 zod 做运行时校验。tRPC 会把 zod 类型映射成 TypeScript 类型前端调用create时如果不传title或传了超过 5000 字的content会在请求阶段就收到 400不用等后端逻辑跑一遍才发现。第三步是前端页面。在服务端组件里直接查列表在客户端组件里处理提交动作。列表使用useQueryuse client; import { api } from /trpc/react; const utils api.useUtils(); const { data: posts, isLoading } api.post.list.useQuery(); const createPost api.post.create.useMutation({ onSuccess: () { utils.post.list.invalidate(); form.reset(); }, });onSuccess里调用utils.post.list.invalidate()意思是让 React Query 重新拉取列表数据。你不需要手动更新本地 state也不用发额外请求去处理缓存这个缓存失效的模型非常省心。在很多项目中列表刷新靠window.location.reload()或setState不仅体验差还容易产生重复代码。第四步是 UI 组装。页面用一个表单收集title和content提交时调用createPost.mutate()。因为createPost是protectedProcedure未登录用户会被 tRPC 中间件拦截前端可以拦截到UNAUTHORIZED错误并跳转到登录页。整个功能到这里就完整了代码量并不大。3.3 用 seed 脚本制造联调数据本地开发最烦的是数据库里没有数据页面上一片空白。我在 t3code 里加了一个 seed 脚本方便每次重置数据库之后快速填充测试数据。在prisma/seed.ts里写一个简单的数据生成逻辑import { PrismaClient } from prisma/client; const prisma new PrismaClient(); async function main() { const user await prisma.user.upsert({ where: { email: demoexample.com }, update: {}, create: { name: Demo User, email: demoexample.com, posts: { create: [ { title: 第一篇文章, content: 这里是内容。 }, { title: 第二篇文章, content: 这里是另一段内容。 }, ], }, }, }); console.log(Seeded user ${user.id}); } main() .catch((e) { console.error(e); process.exit(1); }) .finally(async () { await prisma.$disconnect(); });执行的时候pnpm prisma db seed用upsert而不是create是为了防止重复执行时因为唯一约束报错。seed 数据不要放真实手机号和身份证号所有测试数据都用占位符避免安全事故。3.4 在 Vercel 上完成部署t3code 部署到 Vercel 很简单。先把项目推到 GitHub然后在 Vercel 上新建项目导入仓库。Vercel 会自动识别 Next.js安装依赖并执行 build。部署前需要在 Vercel 的 Environment Variables 里填入所有环境变量。特别是DATABASE_URL如果用的是外部数据库连接串必须包含 SSL 参数。很多数据库服务商会默认拒绝非 SSL 连接Vercel 的服务器和你的本地 IP 不一样越早按生产环境配置越好。构建通过之后再执行一次数据库迁移。我通常会在部署的 build 阶段加一行pnpm prisma migrate deploy pnpm next build这样每次部署时Vercel 会先跑数据库迁移再构建应用。如果迁移失败构建也会失败不会出现页面已经更新但数据库表没建好的尴尬。Vercel 会给每个分支生成独立的预览地址。团队协作时这个预览环境可以直接拿给产品看不用自己本地起服务再截图。不过预览环境最好也连同一个 staging 数据库否则只改了一半 schema 的代码跑在旧表上一访问就会报错。4. 常见问题与排查技巧实录4.1 tRPC 类型卡住不更新的解决流程我的体感是 T3 Stack 开发中最高频的问题是类型不刷新。改完 router 里的输入类型或返回值类型前端 IDE 里还是显示旧类型甚至运行起来也不报错。遇到这种情况最有效的操作不是到处找类型定义文件而是停掉 dev server删掉.next目录然后重启。如果还不行把node_modules/.cache也删掉。tRPC 的类型生成依赖 Next.js 的编译缓存缓存一坏就会出现改了等于没改的错觉。还有一个经验tRPC router 嵌套层级不要超过两层。超过之后类型推导需要遍历大量交叉类型IDE 响应会变慢也更容易出现缓存问题。t3code 里我把路由设计成rootRouter下面挂postRouter、userRouter这种平铺结构每个 router 内的 method 不超过四五个运行很顺。4.2 Prisma 的 Date 序列化、BigInt 与 undefined第一次从 tRPC 接口拿到文章列表时发现createdAt变成了 ISO 字符串而不是 Date 对象。我愣了一下后来明白了tRPC 返回数据时要经过 JSON 序列化Date 自动变成字符串这是正常现象不算 bug。前端处理时直接new Date(item.createdAt)可以转回日期对象。如果项目里用了 superjsoncreate-t3-app 默认在trpc/react.tsx里已经注册了Date 类型其实可以原样保留。superjson 还会处理 BigInt但代价是数据包变重。t3code 里我特意不用 BigInt 作为默认数据类型能用 Int 的地方都用 Int避免序列化和精度问题。另一个容易踩的坑是 undefined 字段。Prisma 查询结果里可空字段不会返回undefined而是返回null。但 zod 校验输入时z.string().optional()和.nullable()是两种不同行为。写前端代码时我养成了一个习惯凡是数据库可空字段一律用?? 给默认值不要在渲染层做if (item.nickname)判断因为空字符串和 null 都可能是合法的空。4.3 NextAuth 登录跳转与回调的坑登录功能在本地正常部署上线后跳到一个陌生域名或一直回不到原页面这种问题十有八九是NEXTAUTH_URL没配置对。NextAuth 会在初始化时读取这个变量如果没设置或设置成http://localhost:3000线上环境就会用错误的地址。正确做法是显式设置NEXTAUTH_URL为当前环境的站点地址同时NEXTAUTH_SECRET用一个稳定的随机值openssl rand -base64 32还有一个关于 callbackUrl 的细节。前端跳转到登录页时最好把当前页面地址作为 callback 传进去。t3code 里我封装了一个登录按钮组件统一从usePathname()读取当前路径再传给signInsignIn(github, { callbackUrl: pathname ?? /, });不然用户登录完成后总会被踢回首页体验很割裂尤其在编辑长表单时尤为致命。4.4 部署环境下的数据库连接问题本地一切正常部署到 Vercel 后页面能打开但列表没有数据。排查顺序非常重要。先看环境变量是否真的传到构建和运行时了。Vercel 的环境变量分 Production、Preview、Development 三组很多人只在 Production 填了预览环境就漏了。最容易漏的是数据库连接串里没有 SSL 参数外部数据库为了安全会拒绝非 SSL 连接。用 PostgreSQL 的话连接串后面要加?sslmoderequire或按服务商要求配置。然后确认数据库表是否已经创建。本地用prisma db push建的表线上可不会自动复制。部署后要执行prisma migrate deploy或者让构建命令里的迁移步骤跑一遍。最后再检查数据库白名单如果数据库厂商要求限制来源 IPVercel 的出口 IP 会变不要把白名单写死成自己的家庭宽带。4.5 常见问题速查表现象可能原因处理办法启动报 env 校验失败缺少环境变量或变量格式不对逐个检查.env.local重点看DATABASE_URLtRPC 调用返回 401未登录或 session 未注入 context在 tRPCcreateContext中读取 NextAuth session提交表单返回 400zod 校验不通过看响应体的issues字段确认字段名和长度限制列表没有数据数据库连接错误或表不存在先跑prisma migrate deploy再看连接串页面样式丢失Tailwind 动态类名被清理避免字符串拼接类名使用完整的映射表部署构建失败API 路径被客户端引用只 importsrc/trpc/react不要引用src/server这个表格基本能覆盖我日常开发中 80% 的异常情况。剩下 20% 要靠明确报错信息去搜只要类型链路完整问题一般都集中在这几个地方。做到这里t3code 这个代号下的核心内容就拆解得差不多了。整条链路里给我最大安全感的是改错有提示这个特性。数据库字段改名后前端所有引用点都会报红而不是等线上用户发现某个数据消失了。如果你也在做全栈项目我建议先别急着上复杂架构把 Next.js、tRPC、Prisma 这三层跑通比堆砌十个微服务更有价值。这个项目下一步我会继续扩展评论和通知模块本质上还是先加 Prisma 模型、再补 tRPC router、最后接前端页面套路熟了之后业务再多也是重复劳动。