ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

T3 Stack 全栈工程实战:Next.js + TypeScript + tRPC 类型安全架构解析

T3 Stack 全栈工程实战:Next.js + TypeScript + tRPC 类型安全架构解析 先说说 t3code 是什么。它不是一个框架也不是某个大厂的开源项目而是我最近集中精力打磨的一套全栈工程样例以 Next.js TypeScript 为核心集成了 Tailwind CSS、Prisma、tRPC 等一套组合目标是把“从零到能稳定上线”的全过程固化下来让新项目不用总是重复踩坑。这套组合有一个社区里的普遍叫法T3 Stack。T3 这个概念最早来自对“TypeScript-first 全栈开发”的总结核心是相信扁平的端到端类型安全能减少大量无效沟通和低级错误。我之所以给项目起名叫 t3code就是想把它当作一套可以不断复用的代码底座既能承载真实业务也可以作为团队内部脚手架甚至拿来对新人做全栈入门教学。下面这篇文章会把整个项目的设计思路、目录结构、数据模型、API 层、前端交互、测试部署和我在实际开发中踩过的坑完整讲一遍。适合有一定 React 基础、但对全栈工程化还比较陌生的读者也适合想优化技术栈选型的团队参考。1. 先把项目拆开t3code 的核心目标与工程边界1.1 这一套组合解决了什么问题前端项目发展到今天困扰大家的往往不是“会不会写组件”而是几个老生常谈的痛点前后端接口文档对不上、类型不一致、配置分散、部署链路太长、新人接手成本高。t3code 选择 T3 Stack 的原因就在于此。Next.js 负责页面、路由和服务端渲染框架本身已经吸收了大量 Web 性能最佳实践TypeScript 严格模式保证代码在编译阶段就把大部分类型问题暴露出来不需要等运行时报错Tailwind CSS 把样式从“命名地狱”里解放出来改排版再也不用担心牵一发动全身Prisma 提供数据库建模、迁移和类型安全的客户端tRPC 让前后端 API 像调用本地函数一样自然类型直接从服务端传到客户端。这一套组合真正解决的核心问题是“认知负载”。你不需要同时维护 REST API 文档、手写类型定义、各种代码生成脚本只需要依赖 TypeScript 编译器和框架本身的约定就能让全链路保持类型安全。1.2 能力边界与技术展望t3code 并不想把所有业务都塞进一套模板里。我在设计的时候明确划了一条边界它负责提供一个成熟的起点而不是一个大而全的应用框架。换句话说它默认帮你把路由、样式、数据库、鉴权、部署这些基础能力搭好但具体业务逻辑需要你按自己的领域去填。比如如果你想做一个博客系统可以直接在 Prisma 里加 Post 模型如果你想做一个小型 SaaS可以在 tRPC router 里定义自己的计费接口。这套底座还有一个隐藏优势因为所有模块都保持解耦后续把某个部分替换成别的方案也不伤筋动骨。比如你不想用 Prisma完全可以换成 Drizzle 或 TypeORM只要 tRPC 路由对外暴露的输入输出不变前端代码几乎零改动。1.3 适用人群这个项目特别适合三类人独立开发者想在短时间内把一个想法做成可演示的 MVP不需要从零搭配置前端团队想统一全栈开发规范降低协作成本特别是成员对 Node.js 后端不太熟悉时教学场景用 t3code 作为实训资料让学生在真实工程里理解 TypeScript、数据库、服务端渲染之间的关系。如果你已经有两年以上的 React/Node 经验这套代码读起来基本没有门槛如果你刚接触全栈建议先把 React 基础过一遍再动手改样式和数据模型。我自己带过几个实习生他们第一次看 t3code 的代码时最大的障碍反而不是语法而是“服务端和客户端到底怎么区分”。这一点在交流里我更倾向用最直白的方式解释src/server 下面的代码不会进浏览器src/app 下面的代码才是页面入口剩下的事交给框架约定。2. 从零搭起 t3code工具链与工程化2.1 初始化工具链不该省的一步我习惯用 pnpm 管理依赖。相比 npm/yarnpnpm 对磁盘空间更友好依赖安装速度也更快更重要的是它默认做了更严格的依赖隔离不容易出现“本地能跑、打包就挂”的问题。初始化 t3code 的时候可以直接用 create-t3-app 命令行工具。执行下面的命令pnpm create t3-applatest t3code交互终端会让你选择需要哪些模块我勾选的是Next.jsTypeScriptTailwind CSSPrismatRPCNextAuth.js鉴权项目生成后先进入目录安装依赖cd t3code pnpm install这里有一个容易被忽略的细节create-t3-app 默认会帮你搭好 .env、代码规范、tsconfig但不会自动创建数据库。所以在真正启动开发服务前你需要先把数据库连接串和认证配置填好。我记得第一次跑的时候漏掉了环境变量结果 tRPC 路由全部报错白排查了好几个小时。还有一个小提醒生成器默认可能在根目录创建 .env 和 .env.example你可以立即把 .env.example 补全为一份“无敏感信息的模板”。我后来在团队协作中受益很大因为新人 clone 下来之后对着 example 文件就能快速知道要配哪些变量不用反复来问。2.2 TS 严格性与项目结构t3code 的 TypeScript 配置走的是“严格到底”的路线。说实话刚开始写会觉得有点烦比如可空类型、未使用变量、隐式 any 都会被编译器拦下来。但这种方式长期看非常划算尤其是项目规模变大之后重构时不会出现“改了一个对象属性十几个页面默默出错”的场面。项目的核心目录结构如下src/ app/ # Next.js App Router 路由与页面 components/ # 可复用的 React 组件 lib/ # 全局工具、数据库实例、认证配置 server/ # 服务端逻辑tRPC 路由、鉴权、上下文 styles/ # 全局样式与 Tailwind 配置 prisma/ schema.prisma # 数据模型与数据库配置这个结构并不复杂但它把服务端和客户端逻辑分得很清楚不会出现业务代码互相纠缠的情况。尤其是 tRPC 的实例、上下文、路由必须放在服务端一旦放错位置很可能会把数据库凭据泄漏到客户端 bundle 里这个风险点需要重点记住。实际开发时我还会顺手加一个 src/env 目录用来集中解析环境变量。create-t3-app 默认会用 zod 对 env 做运行时校验这看起来有点小题大做但如果你在团队里见过“某个变量拼错了导致线上全挂”的案例就会理解这种“一启动就报错”的设计有多友好。2.3 环境变量与配置管理环境变量方面我会在 .env 里维护几类关键配置DATABASE_URLmysql://user:passwordlocalhost:3306/t3code NEXTAUTH_SECRETyour-secret NEXTAUTH_URLhttp://localhost:3000其中 DATABASE_URL 是 Prisma 连接数据库的入口NEXTAUTH_SECRET 是 NextAuth 加密会话用的密钥NEXTAUTH_URL 指定当前应用的地址。这三个是本地开发最基础的三件套不要省略。注意生产环境不要使用本地数据库地址也不要把真实密钥提交到 Git 仓库。我在团队里一直强调.env 文件要进 .gitignore密钥走专门的密钥管理服务或部署平台的 env 配置。顺带一提我在本地经常用 docker 起一个 MySQL 实例来跑开发库。这个方案最大的好处是环境一致不会因为每个人本机装的数据库版本不同而出现奇怪的兼容问题。用 docker compose 起个测试库基本五分钟搞定比手动安装数据库省心。3. 数据层与业务 API从 schema 到 tRPC 的完整链路3.1 Prisma 模型设计先想清楚关系t3code 的数据模型用 Prisma Schema 声明。开发前最好先把“谁和谁有对应关系”想清楚否则后面迁移表结构会比较痛苦。以常见的内容型应用为例generator client { provider prisma-client-js } datasource db { provider mysql url env(DATABASE_URL) } model User { id String id default(cuid()) name String email String unique emailVerified DateTime? posts Post[] } model Post { id String id default(cuid()) title String content String published Boolean default(false) createdAt DateTime default(now()) updatedAt DateTime updatedAt authorId String author User relation(fields: [authorId], references: [id]) }这里有个很典型的例子User 和 Post 是一对多关系Post 通过 authorId 外键关联到 User。我在实际开发中经常看到有人把 createdBy 写成字符串或者在服务端再手写一遍关系逻辑这些都是不必要的重复。Prisma 会根据 schema 自动生成带关系类型的客户端类型安全是天然的。改完 schema 后运行npx prisma migrate dev --name init这个命令会在数据库里创建对应的表并把 migration 历史记录下来方便团队其他成员同步结构和后续回滚。如果只是在本地快速验证也可以先用 npx prisma db push但生产环境我仍然推荐走正式 migration。关于主键类型我默认使用字符串 cuid 而不是自增数字。原因是自增主键容易在数据合并、多环境同步时撞车cuid 则是全局唯一的。虽然字符串索引比数字略慢一点点但对绝大多数业务系统来说完全可接受换来的是更强的可迁移性。3.2 tRPC router 的设计接口即函数tRPC 的设计哲学是“接口即函数”。服务端定义一个 router客户端引入类型后调用起来就像调用本地 Promise 函数一样。一个最简单的 tRPC 查询可以这样写import { createTRPCRouter, protectedProcedure, publicProcedure } from ~/server/trpc; import { z } from zod; import { prisma } from ~/server/db; export const postRouter createTRPCRouter({ list: publicProcedure .input(z.object({ limit: z.number().min(1).max(100).default(10) })) .query(async ({ input }) { const posts await prisma.post.findMany({ take: input.limit, orderBy: { createdAt: desc }, }); return posts; }), create: protectedProcedure .input(z.object({ title: z.string().min(1), content: z.string().min(1) })) .mutation(async ({ ctx, input }) { return prisma.post.create({ data: { title: input.title, content: input.content, authorId: ctx.session.user.id, }, }); }), });这段代码里publicProcedure 和 protectedProcedure 的区别就是鉴权。protectedProcedure 要求用户必须先登录未登录时 tRPC 会抛出认证错误。对于内容创建这类写操作我强烈建议只用 protectedProcedure不要为了省事把所有接口都设为 public。zod 的 input 校验也值得多说两句。很多人会以为它只是“省去 if 判断”实际上它同时兼任类型定义和运行时校验两个职责。你定义了 z.string().min(1)那么客户端传入空字符串时tRPC 会在进入业务逻辑之前就拒绝请求而不是等数据库报错。这对接口健壮性的提升是立竿见影的。3.3 中间件与鉴权NextAuth 和 tRPC 的配合在 T3 Stack 里可以说是“官方推荐组合”。它的流程是NextAuth 负责登录会话tRPC createContext 会读取 session 信息然后注入到每个路由的 ctx 中。我一开始遇到的问题是把 session 类型定义写得不够严格导致客户端拿到的 user 对象可能为 null。后来我加了类型断言和中间件统一处理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 }, }, }); });这个中间件看起来很简单但它把“是否登录”的判断收敛到了一处。后面所有需要登录的接口只要套一个 protectedProcedure就不需要再重复写判断逻辑也不会出现漏判。注意登录态判断永远不要只依赖前端。即使你用了 tRPC前端路由可以写各种守卫但真正的数据访问控制一定发生在服务端。这是安全底线。在 t3code 里我还加了一层资源归属校验。比如用户只能编辑自己创建的文章这个逻辑不能只靠 protectedProcedure还要在 procedure 内部判断 resource.ownerId 是否等于 session.user.id。很多数据泄露事故本质上都是“能登录就能看别人数据”导致的这一点要在业务路由里单独加固。4. 前端体验UI、状态与交互细节4.1 Tailwind 渲染与设计系统t3code 的样式层做得比较克制。Tailwind CSS 很适合“只写该写的样式”不会像传统 CSS 那样产生大量难以维护的类名堆叠。我会先在 tailwind.config.ts 里定义几个 design token比如主色、圆角、阴影import type { Config } from tailwindcss; export default { content: [./src/**/*.{ts,tsx}], theme: { extend: { colors: { primary: #2563eb, accent: #f59e0b, }, borderRadius: { xl: 1rem, }, }, }, plugins: [], } satisfies Config;实际写页面的时候我通常不会把样式复杂化。比如一个按钮直接写 classNamerounded-xl bg-primary px-4 py-2 text-white基本不用再额外写 CSS 文件。这样做的收益是新人接手时不需要翻样式表看到 className 就能猜出大概样式。我还习惯把重复出现的样式抽成小组件。比如一个基础按钮 Button、一个卡片 Card把这些小组件统一放在 components/ui 下面。这样既保留了 Tailwind 的灵活性又避免了同一段样式在二十个页面里反复复制后续想统一改圆角、改阴影时只需要动一个地方。4.2 客户端数据流把服务端数据拉起来在 t3code 里客户端调用服务端并不需要写 fetch 和 URL 拼接。还记得刚才的 postRouter 吗在 React 组件里可以直接这么用use client; import { api } from ~/utils/api; export default function PostList() { const { data: posts, isLoading } api.post.list.useQuery({ limit: 10 }); if (isLoading) return div加载中.../div; return ( ul {posts?.map((post) ( li key{post.id}{post.title}/li ))} /ul ); }这里的 api.post.list.useQuery 是类型安全的如果你在服务端把返回值改成别的字段客户端这里立刻会编译报错。这种体验在传统 REST API 开发里几乎是不可能的也正因为我亲身体验过我才格外推荐 tRPC。写操作时可以用 useMutation它会自动管理请求状态和缓存失效const utils api.useUtils(); const createPost api.post.create.useMutation({ onSuccess: async () { await utils.post.list.invalidate(); }, });这里 invalidate 是 tRPC 提供的一个很实用的能力它会在创建成功后让 post.list 自动重新拉取不用手动去改本地缓存。很多人第一次接触时不太能理解为什么不用手动 setState 去更新列表其实这就是“服务端状态”和“客户端状态”的区别。列表数据来自服务端最稳妥的更新方式就是让服务端重新给一份而不是在前端猜。4.3 性能兜底与加载体验全栈应用很容易出现“数据太慢”的问题。t3code 做性能优化时主要抓三个点页面骨架屏用服务端渲染先出 HTML配合 loading 状态展示骨架减少白屏焦虑数据复用对不常变更的数据使用 React Query 的 staleTime 配置避免每次都发请求按需加载组件层面用 Next.js 的 dynamic import路由拆包避免首屏 bundle 过大。一个典型的示例是在根页面组件里设置 staleTimeconst { data } api.post.list.useQuery( { limit: 10 }, { staleTime: 30_000 } );这样 30 秒内再次访问React Query 不会重新发起网络请求直接使用缓存。对访问量的优化非常明显。这里还要提醒一句React Query 的 cache 默认是内存级的刷新页面后一定会重新拉数据。如果你需要持久化缓存比如把用户偏好设置存到 localStorage那就不能只依赖 React Query 默认行为得配合 persist 之类的方案。大多数场景下默认内存缓存已经够用了不需要过早优化。5. 测试、部署与运维5.1 自动化测试的最小集很多人习惯手动点一遍页面就当测试过了但项目到中后期会越来越脆弱。t3code 里我保留了一个最小可用的测试集单元测试主要覆盖工具函数和 tRPC 路由的输入校验集成测试用 Vitest React Testing Library 渲染关键页面数据库测试在测试环境里跑内存数据库或独立的测试库验证数据模型。Vitest 配置非常轻直接在 vite.config 里加 test 字段就行它的 API 和 Jest 很接近迁移成本低。针对 tRPC 路由测试我一般直接调用 router.createCaller 来模拟客户端请求比真正起一个 HTTP 服务器更省事。import { postRouter } from ~/server/api/routers/post; import { createCallerFactory } from ~/server/trpc; const caller createCallerFactory(postRouter); test(未登录用户不能创建文章, async () { await expect(caller.withContext({ session: null }).create({ title: hello, content: world, })).rejects.toThrow(/UNAUTHORIZED/); });这种测试方式非常贴近真实调用链因为 createCaller 会走完整的 zod 校验和中间件逻辑。只要把 session 传入不同的 mock 对象就能模拟登录和未登录两种场景不用去 mock 数据库也不用起服务器测试速度非常快。5.2 部署与数据库前端部分部署到 Vercel 是成本最低的选择push 到 Git 仓库后自动构建发布日常发布基本不需要手动干预。数据库我一般选择托管数据库服务避免自己维护服务器和高可用开发效率优先。部署前Build 命令需要提前在环境变量里配置好DATABASE_URL指向生产数据库NEXTAUTH_URL生产环境域名NEXTAUTH_SECRET新的随机密钥。在 Vercel 上配置时不要直接复制本地 .env应该重新生成一套生产密钥并限制数据库白名单。很多安全事故其实不是代码问题而是开发环境的密钥直接被带到了线上。有一个经验值得分享生产环境的 DATABASE_URL 要和本地隔离。如果本地和线上共用同一个数据库开发时的误操作很可能会污染生产数据尤其是跑 migrate reset 之类的命令时后果可能是毁灭性的。我自己的习惯是本地、预发布、生产各一套数据库再怎么折腾都不影响线上。5.3 监控与日志t3code 上线后重点观察的数据有三类页面错误率、接口延迟、数据库连接数。如果项目还比较小可以先用框架自带的 logging 和 Sentry 这类服务先顶起来等业务体量大了再引入更重的可观测平台。我的习惯是在 tRPC 路由的外层加一个简单的错误捕获中间件统一记录请求路径、错误码、耗时export const loggingMiddleware t.middleware(async ({ path, type, next, ctx }) { const start Date.now(); try { const result await next(); console.log(${type} ${path} ${Date.now() - start}ms); return result; } catch (err) { console.error(${type} ${path} failed, err); throw err; } });中间件本身不复杂但能把所有接口的可观测性兜住上线后排查问题会省不少力气。有朋友问我为什么不用 Grafana / Prometheus 那套重型方案我的回答是看项目阶段。如果你每天的请求量连一万都不到堆一堆监控系统反而成了维护负担。先把 log 打全、把错误上报链路接好等业务真的需要再逐步加指标系统这才是更务实的选择。6. 常见问题速查与个人心得6.1 高频问题一次说清下面这个表格是我在 t3code 开发过程中反复遇到的几个问题整理出来供大家对照排查症状常见原因排查思路tRPC 请求全部报 500DATABASE_URL 没有配置或者数据库没启动先确认 .env 加载再执行 npx prisma db push 或 migrate登录后 session 丢失NEXTAUTH_SECRET 不一致或 NEXTAUTH_URL 不对检查部署环境和本地环境是否共用同一密钥打包后样式错乱使用 Tailwind 时漏配置 content 路径确认 tailwind.config.ts 包含 src 下的所有 tsx 文件表单校验不生效zod schema 类型和组件 input 类型不一致统一用 z.infer 导出类型给前端用数据更新后页面不刷新没有调用 invalidate 或 refetch在 mutation onSuccess 里调用对应 query 的 invalidate还有一个容易被忽略的点是权限相关。很多“明明登录了却提示未登录”的案例其实是因为 session 在客户端和服务端的 secret 不一致。NextAuth 在做加密和签名时会用到 NEXTAUTH_SECRET本地、Vercel、测试环境如果各用各的就会导致 token 互相解不开。这个坑我踩过不止一次所以现在我会在部署脚本里强制执行一行检查如果某个环境的 secret 和本地相同直接中断部署并给出警告。6.2 我在实际开发中维护的小习惯最后分享几点长期改动下来的习惯。第一t3code 的所有数据库模型都尽量以业务语义命名不在 schema 里堆“技术晦涩”的字段。比如使用 createdAt、updatedAt、authorId而不要用 ctime、utime、owner_id。名字清楚迁移时也容易读懂历史记录。第二每一次修改 Prisma schema 后都立即生成 migration而不是攒到最后一起迁移。中途发现问题时小的 migration 更好回滚和分析。第三不要在客户端代码里直接 import prisma也不要在组件里 import 任何 server-only 文件。t3code 的目录划分已经把 server 目录独立出来这样不仅能避免类型混乱也能防止把数据库连接代码打进客户端 bundle。6.3 后续还能怎么扩展t3code 这套底座想扩展成其他产品形态并不难。想做成多用户协作工具可以在 User 模型加一个 workspaceId再通过 tRPC 中间件做资源隔离想做内容社区可以加 Comment 模型和 Tag 模型想接 AI 能力可以在服务端路由里调模型接口再把结果通过 tRPC 返回前端。关键在于保持类型安全和分层意识新增功能时先修改 schema再定义路由最后写界面不容易把系统写乱。说回最初我起名 t3code 其实没有太复杂的含义就是“T3 栈的代码仓库”的简称。但做完整套东西之后我最大的感受是全栈开发真正要对抗的不是学习难度而是散落各处的信息和不确定性。把不确定的类型、接口、配置都收拢到一处整个开发体验就会好非常多。如果你正准备从零开始做一个全栈产品或者想把团队的技术栈收敛一下建议照这个思路复现一遍 t3code你会少走很多弯路。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表