T3 Stack实战:从零搭建类型安全全栈应用

发布时间:2026/10/28 23:17:20
T3 Stack实战:从零搭建类型安全全栈应用 长久混在前端社区的朋友对t3code这个域名应该不陌生。它最早是 T3 Stack 官方的作品集展示站后来逐渐变成很多人学习 Next.js、tRPC、Tailwind 和 Prisma 这套组合的“样板间”。今天我想聊的不是那个被大家看腻了的 Todo App而是你自己从零搭建一个t3code类项目时真正会被卡住的那些点类型安全到底安全在哪、前后端怎么共享一套类型定义、部署时数据库连接字符串该踩哪些坑。整篇文章会以一个真实的 T3 Stack 项目为主线把架构思路、核心代码细节、部署实操和排错过程全部摊开来讲。如果你正准备用 T3 Stack 起一个新项目或者想把现有项目迁移到 tRPC Prisma 这套体系里接下来这些内容可以直接帮你节省至少一个周末的摸索时间。我会尽量少讲空话把能抄的代码和能落地的方案都写清楚。1. 整体思路与价值拆解1.1 不是“又多了一个脚手架”而是把类型安全变成默认约束很多同学第一次接触t3code最直观的感受是“启动快”。create-t3-app跑一下三秒后项目就起来了。但脚手架只是表象T3 Stack 真正的内核是让前端、后端、数据库三端的类型系统彼此咬合让不安全的联调在编译期就被拦截而不是上线后通过 500 错误去发现。举个例子传统的前后端分离项目里接口文档是沟通的中间层后端改字段、前端没同步更新运行时就崩。T3 体系里tRPC 路由定义在同一个仓库里前端通过typeof router自动推导出完整的输入输出类型。也就是说后端改了返回结构前端代码不跟着改类型检查直接报错——这不是“帮你查错”这是把曾经靠责任心保证的事变成了一道绕不开的物理闸门。我见过很多团队项目里api层错误地返回了data前端却在读result。这种低级问题在有类型约束的情况下根本没有藏身之地。而 Prisma 生成的数据库模型类型又能直接贯穿到 tRPC 的输入里。比如appointment表结构里某个字段是可选前端useMutation的 input 也必然是可选的这层一致性靠的是代码生成不是开发者的记性。1.2 为什么选择 tRPC 而不是传统 REST 或 GraphQLt3code项目里最常被问到的一个问题为什么不用 REST 或 GraphQL而要用 tRPC 这个看起来“不怎么流行”的方案我的看法是这三个方案没有绝对优劣但 T3 场景下有强烈的“全栈同仓”属性tRPC 反而是最贴近组织效率的选择。REST 的问题在于类型穿越边界需要额外工具。你可以用 OpenAPI 生成 client也可以手写 DTO但维护成本和粘贴复制都客观存在。GraphQL 的确有很强的类型体系但绝大多数非大型业务场景下它引入的网关、缓存、resolver 分层会让团队心智负担成倍增加。tRPC 直接复用函数调用语义不管前端在哪个页面trpc.post.list.useQuery()的调用形式本质上就是一次带类型的函数调用。它不生产 REST API也不解释 GraphQL 的 SDL它把开发者的心智拉回最原始的“调用函数”模型。可能有人会觉得tRPC 是不是“不够工程化”。我现在做完完整项目后会说对于中小型全栈应用tRPC 带来的类型推导收益远大于它少掉的部分生态红利。诚然如果你未来要面向第三方开放 API当然还要补一层 REST 接口这从不是二选一。但作为前端后台这类内部系统tRPC 的体验是我目前用过最顺手的。1.3 t3code 适合谁、不适合谁说完选型得给读者一个更实际的判断标准t3code这套东西到底适合谁来做以我的实际体感如果你是一个人在做 side project或者团队规模在 10 人以内且仓库是 monorepo 结构T3 体系几乎是同类方案里学习成本最低、产出效率最高的组合。它把典型的 Next.js SSR 场景、数据库 ORM、API layer 三件事全部内置化你只需要关心业务逻辑。反过来如果你们是几十个前端、几十个后端的独立团队强行上 tRPC 反而会别扭——毕竟 DTO 边界划分、API 版本管理这些诉求tRPC 当前还偏“简约”。这种情况我建议干脆保持 REST 或 GraphQL别为了追热词给自己添乱。2. 核心实现细节与实操要点2.1 项目初始化与目录结构先说初始化。官方脚手架支持交互式选择需要的模块我一般会勾选Next.js、Tailwind CSS、tRPC、Prisma之后再补一个NextAuth.js认证模块。运行以下命令npx create-t3-applatest t3code-project选 TypeScript 时不用犹豫T3 的乐趣本来就是类型安全。目录结构核心长这样src/ pages/ server/ api/ auth/ db.ts trpc/ styles/ prisma/ schema.prisma很多人第一次看server/trpc里的router、procedure有点懵其实不复杂。procedure是 tRPC 的最小执行单元router是多个 procedure 的集合。你可以把router理解成“模块控制器”把 procedure 理解成“接口函数”。2.2 数据库模型的 Prisma 设计t3code这个项目的核心场景是我做的“代码学习打卡社区”那我们先从数据库设计说起。假设用户表、提交表、每日代码打卡表三张核心模型model User { id String id default(cuid()) email String unique name String? isAdmin Boolean default(false) posts Post[] submissions Submission[] createdAt DateTime default(now()) } model Post { id String id default(cuid()) title String content String language String default(javascript) author User relation(fields: [authorId], references: [id]) authorId String createdAt DateTime default(now()) } model Submission { id String id default(cuid()) code String language String status String default(pending) author User relation(fields: [authorId], references: [id]) authorId String createdAt DateTime default(now()) }这里有个很关键的实操习惯npx prisma migrate dev会生成 migration 文件并自动将 schema 同步到数据库但在团队多人协作时每个成员本地都会跑 migrate容易产生 migration 冲突。我现在更倾向只用prisma migrate dev做开发环境迁移生产环境用prisma migrate deploy来执行已经评审过的 migration 文件。2.3 tRPC Router 的上下文与受保护路由tRPC 的精华在 context。你可以把它理解成每个请求进入路由处理器之前先经过的一道“环境装配线”。在src/server/api/root.ts里通常会导出一个createTRPCContext用于注入当前会话和数据库实例import { getServerAuthSession } from /server/auth; import { prisma } from /server/db; import { TRPCError, initTRPC } from trpc/server; export const createTRPCContext async (opts: { headers: Headers }) { const session await getServerAuthSession(); return { db: prisma, session, ...opts, }; }; const t initTRPC.contexttypeof createTRPCContext().create(); export const protectedProcedure t.procedure.use( t.middleware(({ ctx, next }) { if (!ctx.session?.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { session: ctx.session } }); }) );注意protectedProcedure这里的写法。从广义上讲它就是一个中间件拦截器未登录用户直接抛401语义的错误。有了这个protectedProcedure后面定义“只有登录用户才能执行的接口”就不必在每个 procedure 里重复写鉴权代码了。2.4 校验层有了类型别忘了 ZodT3 项目标配 Zod但很多新手容易忽略它在 tRPC 里的重程度。tRPC 的 input 校验用 Zod 并不是“形式主义”而是为了把外层不可信的入参挡在业务函数之前。拿我的打卡功能举例import { z } from zod; const createPostSchema z.object({ title: z.string().min(1).max(80), content: z.string().min(8).max(5000), language: z.enum([javascript, typescript, python, rust]), }); protectedProcedure .input(createPostSchema) .mutation(async ({ ctx, input }) { const post await ctx.db.post.create({ data: { title: input.title, content: input.content, language: input.language, authorId: ctx.session.user.id, }, }); return post; });z.enum这里很值得展开。数据库字段如果只写了String那前端传一个javascripts这种拼写错误只有业务执行时才会暴露。而如果用 Zod 做定义前端调用方会自动得到一个联合类型javascript | typescript | python | rust输入时就杜绝了非法值。这种层层叠加的类型约束才是t3code让人觉得“稳”的根源。3. 实操过程与关键环境调试3.1 完整搭建一个打卡功能模块我用“每日代码打卡”这个真实模块来把流程串起来。页面交互是这样的用户在输入框里选择语言粘贴今天的代码片段点击提交然后进入个人动态列表查看历史记录。第一步写 tRPC Router在src/server/api/routers/post.ts里定义create和getAll两个 procedureexport const postRouter createTRPCRouter({ create: protectedProcedure .input(createPostSchema) .mutation(async ({ ctx, input }) { return ctx.db.post.create({ data: { ...input, authorId: ctx.session.user.id }, }); }), recent: publicProcedure .query(async ({ ctx }) { return ctx.db.post.findMany({ take: 20, orderBy: { createdAt: desc }, include: { author: { select: { name: true } } }, }); }), });这里故意用了publicProcedure来暴露“社区最新打卡”列表避免首屏还需要登录才能查看。路由拆分粒度不要太粗也别把几十个方法堆在一个router里按业务域拆是最容易维护的方式。第二步前端调用前端页面里通过useQuery拉取社区最新动态import { api } from /utils/api; const { data: recentPosts, isLoading } api.post.recent.useQuery();提交打卡的表单则直接调用useMutation提交成功后让 recent 列表自动失效、重新拉取const utils api.useUtils(); const createPost api.post.create.useMutation({ onSuccess: async () { await utils.post.recent.invalidate(); }, }); const handleSubmit (values: { title: string; content: string }) { createPost.mutate({ ...values, language: selectedLanguage }); };在这里我可以负责任地说从我做完这个模块至今没有写过一行“手动请求”的代码也没有定义过前端接口类型所有调用都像是直接操作本地函数。3.2 调试技巧怎么确认类型推导正确很多人第一次用 tRPC会被报错整懵最常见的是“类型不匹配”或者编辑器里根本没有提示。80% 的情况下这源于你没有在src/utils/api.ts里正确导出api对象。常见的utils/api.ts配置长这样import { createTRPCReact } from trpc/react-query; import { type AppRouter } from /server/api/root; export const api createTRPCReactAppRouter();关键就在createTRPCReactAppRouter()这个范型。如果 AppRouter 的类型推导不完整前端 Hook 的返回值就会变为 any 或显示无法找到对应recent属性。此时建议优先检查src/server/api/root.ts是否以merge方式正确挂载了所有子路由tsconfig.json里的strict是否开启关闭 strict 会削弱大量类型检查能力有没有在next.config里开启 SWC 插件或路径别名干扰了类型解析。3.3 认证与登录的无缝集成t3code既然要做社区用户体系就不能省。我选择 NextAuth.js 的 GitHub Provider 作为起步重点设置callbacks把 session 里的用户 id 传递到前端。callbacks: { session({ session, token }) { session.user.id token.sub!; return session; }, },这样在任意后端 procedure 里ctx.session.user.id可以直接拿到登录用户的主键用于写authorId。整个登录体验比标准 JWT 方案更紧凑不需要自己维护 token 刷新也没必要单独设计注册接口社交登录直接把第一道身份边界包给了第三方。有一点要提醒的是生产环境里设置 NextAuth 的secret千万别漏且不要用开发默认值。一旦跨进程重启或部署到 Serverless 平台session 解密会频繁失败症状就是用户明明登录成功刷新一次就丢失。3.4 部署从本地到线上的核心配置这里我直接放一套久经测试的 Vercel Neon 部署思路。如果你项目用的是 Postgres本地也许用 Docker 跑一个postgres:16线上可以先用 Neon 的免费额度托管。好处是 Vercel 的 Serverless 函数冷启动并发时Neon 的连接池能帮你把很多连接问题抹平。DATABASE_URL和DIRECT_URL要特别注意区分DATABASE_URLpostgresql://.../neondb?sslmoderequirepgbouncertrue DIRECT_URLpostgresql://.../neondb?sslmoderequirePrisma 在 Neon 上跑迁移时要用DIRECT_URL而应用运行时走连接池用DATABASE_URL。我之前第一次部署时只配了一个DATABASE_URL本地一切正常但到了 Vercel 环境就开始报 prepared statement 相关错误就是这个原因。prisma/schema.prisma里的 datasource 也需要相应调整datasource db { provider postgresql url env(DATABASE_URL) directUrl env(DIRECT_URL) }部署前别忘了在本地跑一下npx prisma migrate deploy另外对于 Vercel 构建流程来说如果构建日志里出现 Prisma 引擎找不到的错误通常是 Prisma 2.x 时代的老问题。现在用 Prisma 5.x 基本不存在但如果你公司的构建环境做了受限 ARM 隔离那就得考虑在package.json里增加 postinstall 钩子来强制生成客户端scripts: { postinstall: prisma generate }4. 常见问题与避坑实录4.1 tRPC 响应序列化问题在 tRPC 的处理过程中Date类型默认会转成字符串返回这本身没问题可如果你在useQuery的select回调里做二次处理可能会遇到“时间格式不一致”的错觉。实际上只要后端返回的createdAt到了前端是一个 Date 对象React Query 会帮你反序列化你直接渲染到界面上不会出错。但如果还有一层JSON.stringify留在某处时间就变字符串了。4.2 “为什么后端改了 schema前端类型没变”这个也是高频问题尤其在多人协作或你自己从别的分支切换过来的时候。Prisma 生成类型逻辑基于client产物有时没有自动触发 Regeneration。常见解法npx prisma generate然后再重启 next dev。如果你发现重启了还不行就把.next或node_modules/.prisma删掉再重新安装依赖。别慌这类“幽灵类型”问题基本都是产物缓存。4.3 NextAuth 与 tRPC 的鉴权错误码排查当你在调用protectedProcedure时收到UNAUTHORIZED错误很容易误以为 NextAuth 配置有问题。但大多数时候其实是getServerAuthSession()读取 cookie 时机不对或者是在非 Server Component 里调用导致的。要确认的话可以在浏览器 DevTools 里看请求 Cookie如果看到next-auth.session-token存在那 NextAuth 本身没问题问题大概率出在你的context获取逻辑上。4.4 表单校验的错误提示混乱用 React Hook Form 配合 Zod 时错误信息有时看起来“滞后”或不对应字段原因通常是 zodResolver 的 schema 和 input schema 不是同一个。tRPC 的 input 校验只发生在请求边界而表单校验发生在用户交互边界两者各自独立。我现在的做法是定义一套sharedSchema放在/src/schemas目录表单和 tRPC input 都从这个 schema 引入保持单一事实来源。这样前端提示和后端校验永远对得上后续要加字段也只在同一个文件里改。4.5 部署后刷新 404这个坑其实不完全在 T3 生态而是 Next.js 传统问题。如果你用的是动态路由/post/[id]并且部署目标不是 Vercel 而是自己配置的 Node 服务器需要确保 rewrite 规则把所有非静态请求都交给 Next.js 的 server handler。常见配置是用pm2跑next start或者用 Nginx 做try_files回退到 Node 端口。很多人部署t3code时忽略了analytics和vercel这类内置依赖最后虽然打包成功但功能失效。记住t3-oss/env-core这类依赖是运行期依赖放dependencies而不是devDependencies否则在精简依赖的生产构建里会被裁剪构建直接崩。5. 更多经验之谈做完t3code这个项目我个人的体感是T3 Stack 的价值不在于它把多少库打包在了一起而在于它把正确的默认做法框定好了——类型安全不是“可选项”而是像空气一样默认存在于所有代码路径里。你不需要在 REST OpenAPI TypeScript 多重组合之间来回做技术选型只需要顺着框架给出的路线走代码质量自然就提上来了。如果再往深走一步我觉得可以把这个项目继续扩展成完整的产品给代码片段增加评论、点赞、关注、标签筛选或者用 PWA 做离线访问。数据库侧只要按业务域扩展 Prisma model再在 router 层面向下添加 procedure类型体系会自动接管。对于 junior 开发者来说这套路径本身就是一次很好的脚手架学习对于已经工作几年的 senior 来说重新体验一次“全栈丝滑联调”也挺值得。最后分享一个实用习惯建议每次提交代码前都跑一遍tsc --noEmit和prisma validate。这两个命令的成本几乎为零但能拦截掉绝大多数类型或 schema 层面的低级错误。我在开发t3code的后期每次部署前跑这三条命令已经成了肌肉记忆。它们不复杂却能让你在一晚上改完功能后安心合上电脑去睡觉。

关于本文作者

来自尧图内容编辑团队

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

尧图内容编辑团队

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

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

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

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

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

网站改版的5个关键决策

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

获取专属建站方案

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

立即免费咨询