资讯动态

T3 Stack 全栈实践:Next.js + TypeScript + tRPC + Prisma 端到端类型安全开发

发布时间:2026/10/27 23:23:34 来源:尧图企业网站定制
最近我把一个内部工具项目用 t3code 这套工程化方案重写了一遍也就是大家说的 T3 Stack——Next.js TypeScript Tailwind CSS tRPC Prisma 的组合拳。老实说写之前我对端到端类型安全这个口号是将信将疑的真把业务跑通之后才理解这东西对开发效率的提升完全是实打实的不是玄学。如果你是一个受够了前端写接口联调、后端给文档、类型全靠猜的全栈开发者或者你是独立开发、小团队想用一套技术栈把前后端一口气吃掉那 t3code 这套方案值得你花一个下午认真过一遍。这篇文章我尽量不念文档按照我当时从零搭项目的真实路径来写包括为什么选它、核心代码怎么写、以及我在实际使用中踩过的坑和排查思路。1. t3code 这个项目到底在做什么1.1 T3 Stack 的组成与选型逻辑先别被 T3 这个名字唬住它不是什么高深框架而是把几个当下 Web 开发里最顺手的工具按全栈 类型安全的逻辑组合起来。拆开看就是下面这张表组成部分职责解决什么问题Next.js应用框架同时提供服务端渲染、路由、API 能力一套代码搞定前后端TypeScript类型系统让变量、函数、前后端数据结构都有显式类型Tailwind CSS样式方案原子化 CSS不用在类名和样式文件之间反复横跳tRPCRPC 调用层让前端像调用本地函数一样调用后端接口全程类型自动推导PrismaORM 层数据库建模、迁移、类型安全的查询客户端有人会问为什么一定是这五个我的看法是它们各自的选型逻辑非常一致——都是开发者体验优先且都原生支持 TypeScript。比如后端接口这一层传统方案是 Express/Fastify REST 或 GraphQL然后手动维护接口文档、生成类型声明。t3code 用 tRPC 直接省掉这步你在服务端定义一个函数客户端立刻获得对应的类型签名连再定义一遍 types这一步都免了。数据库这块Prisma 也提供了从 schema 定义到查询结果的完整类型链路模型改了前端代码的提示跟着变不会出现数据库字段改了你不知道这种尴尬。再比如样式Tailwind 的原子类 服务端组件的组合让页面代码里同时能看到结构和样式不需要来回切文件。虽然这属于个人偏好但对小团队和独立开发者来说这种尽量少上下文切换的开发体验确实是效率提升的关键。1.2 类型安全带来的开发体验质变我拿一个生活化的类比来解释什么叫端到端类型安全传统的接口联调像是两个国家之间靠外交信函沟通信上写什么格式、需要什么签名全靠约定一旦约定没更新就会出岔子t3code 更像你俩坐在同一张桌子前用同一种语言对话你说我要什么对方直接按你的措辞回应根本没有翻译损失。具体落到代码上你在后端定义了一个createPost函数要求传入title: string和content: string前端在调用时如果传了数字编辑器直接标红不用等运行时报错。数据库返回的对象结构也由 Prisma 推断页面拿到数据后post.title能不能用、createdAt是什么类型全部在编译期就锁死了。这个项目适合谁我认为最合适的是全栈开发者、独立开发者、以及三五个人的小团队。因为这套方案把后端路由定义、数据库模型、前端调用全压在一个代码仓库里成员少反而更好发挥。如果是大团队前后端分离、各有各的发布节奏t3code 反而不一定能用上因为 tRPC 强依赖前后端同时部署、共享代码。2. 从零搭建 t3code 项目的完整实操2.1 create-t3-app 初始化与选项取舍t3code 最大的幸运是有官方脚手架不需要手动把 Next.js、tRPC、Prisma 一个个拼起来。直接跑npm create t3-applatest t3code它会以交互方式问你选哪些模块。我当时的选项和理由如下Next.js App Router选中。相比 Page RouterApp Router 的 Server Component 能直接调用 tRPC省掉一层客户端先请求、再渲染的等待这个对体验影响很明显。TypeScript必选t3code 的核心逻辑就是这个不选没法玩。Tailwind CSS选中。如果你喜欢 CSS Modules 或 styled-components可以去掉但 Tailwind 在生成页面时确实快。tRPC选中。这是 t3code 区别于普通 Next.js 项目的灵魂。Prisma选中。数据库访问层后面我写的所有例子都依赖它。NextAuth.js看需求。如果项目不需要登录可以先不选后面要用再手动加也不麻烦。我当时要做一个带用户体系的内容管理工具所以选了。Drizzle如果 Prisma 和 Drizzle 同时出现选 Prisma 即可避免重复。初始化结束后npm run dev就能看到一个默认页面。这时候建议先不要急着改代码花 10 分钟把生成目录扫一遍理解它的结构再动手后面会省很多事。2.2 生成后的目录结构与数据流脚手架会在你的项目里生成类似下面的结构src/ ├── app/ │ ├── api/ │ │ └── trpc/ │ │ └── [trpc]/route.ts │ ├── layout.tsx │ └── page.tsx ├── components/ ├── server/ │ ├── api/ │ │ ├── routers/ │ │ │ └── post.ts │ │ └── root.ts │ ├── auth.ts │ └── db.ts ├── trpc/ │ ├── react.tsx │ ├── server.ts │ └── shared.ts └── styles/第一次看这个结构可能会蒙其实只要抓住一条主线——数据从数据库流向页面的路径src/server/db.ts创建 Prisma Client负责数据库连接。src/server/api/routers/里定义业务逻辑比如postRouter。src/server/api/root.ts把各个子路由汇总成 appRouter。src/app/api/trpc/[trpc]/route.ts是 HTTP 入口把 tRPC 请求接入 Next.js。前端通过src/trpc/react.tsx或src/trpc/server.ts提供的 API 调用这些路由。这个结构的好处是无论你之后加多少个业务模块都只需要遵循新增长文件到 routers 目录、挂载到 root这一个模式不需要设计新的架构规则。我当时第一个功能就是这么加出来的写一个 post 路由挂到 root页面里直接调用半小时打通全链路。3. 核心功能实现从数据库到页面的完整链路3.1 Prisma 数据模型与迁移我从一个最简单的文章发布功能入手。先在prisma/schema.prisma里定义一个 Post 模型generator client { provider prisma-client-js } datasource db { provider postgresql url env(DATABASE_URL) } model Post { id Int id default(autoincrement()) title String content String createdAt DateTime default(now()) updatedAt DateTime updatedAt }模型定义好之后选择迁移方式。t3code 的坑点在于配置里给了两种用法很多人上来就prisma db push结果一改模型就丢数据。我的建议是npx prisma migrate dev --name initmigrate dev会生成迁移记录文件之后每次改模型都跑一条新的迁移团队协作时数据结构的变更历史一目了然。db push更像临时改表适合开发初期试字段不适合正式流程。迁移完成后在代码里使用 Prisma Client。脚手架里的src/server/db.ts已经把客户端初始化好了注意它用了全局单例模式防止开发环境热重载时创建多个连接。如果你是自己手动搭的一定记得import { PrismaClient } from prisma/client; const globalForPrisma globalThis as unknown as { prisma?: PrismaClient }; export const db globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV ! production) { globalForPrisma.prisma db; }这个模式能避免开发模式下 Prisma Client 实例被反复创建导致连接数爆掉。3.2 tRPC 路由的写法和校验有了 Prisma 模型接下来就是 tRPC 路由。在src/server/api/routers/post.ts里写import { createTRPCRouter, protectedProcedure, publicProcedure } from ~/server/api/trpc; import { z } from zod; export const postRouter createTRPCRouter({ list: publicProcedure .query(async ({ ctx }) { return ctx.db.post.findMany({ orderBy: { createdAt: desc }, }); }), create: protectedProcedure .input(z.object({ title: z.string().min(1).max(100), content: z.string().min(1), })) .mutation(async ({ ctx, input }) { return ctx.db.post.create({ data: { title: input.title, content: input.content, createdById: ctx.session.user.id, }, }); }), });这里有两个核心点值得细说。第一publicProcedure 和 protectedProcedure 的区别。前者不需要登录就能访问后者会先检查 Session 是否存在不存在直接抛错。这个是在脚手架配置 NextAuth 时自动接好的你不需要在路由里手动判断用户是否有登录权限类型系统已经把上下文里的session给到了。第二zod 校验的威力。z.object({ title: z.string().min(1).max(100) })这段不只是运行时校验它同时承担了 TypeScript 类型推断的职责。前端调用createPost时如果 title 传了一个数字或者超过 100 字符编辑器里立刻会有提示请求根本发不出去。写完子路由后别忘了在src/server/api/root.ts里挂载import { postRouter } from ~/server/api/routers/post; export const appRouter createTRPCRouter({ post: postRouter, }); export type AppRouter typeof appRouter;这一步是很多人 404 的根源后面我会专门讲排查思路。3.3 页面上的调用方式把类型的魔法落实到页面tRPC 提供了两套调用方式服务端组件里直接调用函数客户端组件里走 React Query hooks。两者各有使用场景我分别演示。服务端组件调用适合首屏渲染// app/page.tsx import { api } from ~/trpc/server; export default async function HomePage() { const posts await api.post.list(); return ( main classNamemx-auto max-w-2xl p-8 h1 classNametext-2xl font-bold最新文章/h1 ul classNamemt-4 space-y-2 {posts.map((post) ( li key{post.id} classNamerounded border p-3 h2 classNamefont-semibold{post.title}/h2 p classNametext-gray-600{post.content}/p /li ))} /ul /main ); }注意api.post.list()作为一个 async 函数直接 await不需要在客户端发请求。这个场景非常适合 SEO 和首屏速度要求高的页面。客户端组件调用适合交互型页面比如列表后面的加载更多、评论区use client; import { api } from ~/trpc/react; function PostList() { const { data, isLoading, error } api.post.list.useQuery(); if (isLoading) return div加载中.../div; if (error) return div加载失败{error.message}/div; return ( div {data?.map((post) ( div key{post.id}{post.title}/div ))} /div ); }只要在页面应用中安装过TRPCReactProvider脚手架会在layout.tsx里配好这些 hooks 直接就能用。我个人的体会是首页、详情页这些偏展示的用服务端调用需要缓存、自动重试、监听状态的交互区用客户端 hooks两者配合下来开发效率非常舒服。4. 实战中踩过的坑与排查清单4.1 类型老是标红先分清三类问题我在用 t3code 过程中90% 的 TypeScript 报错其实是三个原因造成的全部排查过之后基本就没有莫名其妙红了Prisma Client 没有重新生成。每当你改了schema.prisma必须执行npx prisma generate。模型的新字段不会自动出现在类型定义里忘了生成就操代码编辑器一定标红。环境变量缺失。DATABASE_URL、NEXTAUTH_SECRET没有配好会导致启动阶段直接报错而且错误提示比较隐晦。建议新建一个.env文件参考脚手架的.env.example把每一项都填全。zod 输入和数据库字段类型对不上。比如前端传的是 string 的 id数据库是 InttRPC 的类型推导会把不一致暴露在编译阶段。解决办法是统一在 zod 里做z.number()或转换。遇到标红不用慌先执行npx tsc --noEmit看完整错误列表比 IDE 里一个个点效率高得多。4.2 tRPC 路由 404 或请求超时怎么查这个是我初期踩得最深的一个坑现象是页面加载正常但一调用某个 procedure 就 404 或转圈半天。按下面的顺序查基本能定位确认子路由挂载了。在root.ts引入并在createTRPCRouter里注册。忘了挂载的子路由在客户端类型里根本不存在IDE 会提示但如果用字符串调用少数非类型调用场景就会 404。确认route.ts存在。src/app/api/trpc/[trpc]/route.ts是 HTTP 入口如果它不存在或被误删了所有请求都会 404。确认运行环境配置正确。比如在服务端组件里调用 tRPC 时如果NEXTAUTH_URL配置不对中间件的 Session 检查会导致请求挂起表现为超时而不是快速报错。检查 URL 前缀。如果你部署在子路径下比如https://site.com/blog需要在trpc/server.ts和trpc/react.tsx里配置url: /blog/api/trpc否则路径对不上。4.3 Prisma 连接被拒和环境切换问题开发环境一切正常部署到生产环境就报数据库连不上这个问题的根源往往不是密码错而是连接串的作用域不对。例如本地.env里的DATABASE_URL用的是localhost:5432部署到线上还是这个串远程数据库自然拒连。有些云数据库要求 SSL但连接串没加?sslmoderequirePrisma 会一直握手失败。我的做法是本地数据库用 Docker 起一个 PostgreSQL 容器项目里维护三份环境文件.env本地、.env.production生产各写各的连接串绝不混用。另外生产环境不要直接暴露数据库端口尽量让应用和数据库处在同一个内网。4.4 部署到 Vercel 前必须检查的项目t3code 对 Vercel 的适配非常友好Next.js 和 Vercel 本来就是一家人但有几个检查项我每次部署都会过一遍Prisma generate 要跑在构建阶段。Vercel 的构建脚本里需要包含npx prisma generate否则 Serverless 环境里找不到 Prisma Client。可以在package.json的postinstall里加。环境变量重新填一遍。Vercel 里把DATABASE_URL、NEXTAUTH_SECRET、NEXTAUTH_URL全部重新配置不要依赖本地.env。Serverless 函数的执行时长限制。Vercel 默认函数超时时间是 10 秒Hobby 计划如果用 Prisma 做复杂查询可能在冷启动时超时。优化的办法是减少查询条数、加索引或者把冷启动必经的查询缓存起来。数据库连接数。免费版 PostgreSQL 数据库的连接池通常很小而 Serverless 环境多个冷启动实例会同时建连。建议开一个 PgBouncer 之类的连接池或者在 Prisma 连接串里配置connection_limit5之类的参数。这几个点如果都过了t3code 项目部署基本是一次成。我自己第一次部署时就是忘了postinstall里的 prisma generate结果构建成功但运行时疯狂报错排查了快一小时才反应过来。5. 写在最后的一点体会聊了这么多最后说点我个人真实的使用感受。现在我手头几个内部项目都迁移到了 t3code 这套方案上最直观的变化是接口文档基本不写了——tRPC 的类型签名本身就是文档前端调用时鼠标一悬停就能看到参数和返回值结构比看任何文档都直观。数据库模型变更后前后端会同步出现类型漂移这种改一处、全链路报错提醒的体验在传统架构里真的很难实现。当然它也不是银弹。如果团队规模大、前后端独立部署、接口需要给第三方用tRPC 的紧耦合反而会成为束缚。但如果你是个人开发者或三五人的小团队想用一个下午把全栈效率提上一个台阶t3code 这套组合拳非常值得试。项目里如果后面要加定时任务、事件队列这类基础能力我建议先在 tRPC 前面做一层薄薄的适配层避免业务逻辑被框架锁死。就这些希望能给正在评估这套方案的朋友提供点实际参考。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑