☰
基于T3 Stack的t3code:从Next.js到Prisma的全栈类型安全实践
2026/10/27 16:03:52 网站建设 项目流程

最近把团队一个内部工具项目从传统的 Next.js 加手写 API 结构,整体迁移到了一套命名为 t3code 的代码基底上。这套代码没做什么花哨的事情,核心就是把 T3 Stack 的推荐实践落地成一份可复用的工程模板:Next.js 负责页面和接口入口、TypeScript 管住类型、Tailwind 处理样式、tRPC 直接打通前端到数据库的类型链路,再加上 Prisma 做数据建模。如果你正在经历前后端联调时字段对不上、改了后端接口前端编译报错、或者 API 文档永远滞后于代码这些问题,这篇文章应该能给你一些非常具体的参考。从零初始化到部署上线的完整过程我也整理在下面了,适合受够了类型断层、想在一个全栈项目里把 TypeScript 的收益吃满的开发者。

1. t3code 到底解决什么问题

1.1 全栈开发最大的隐形成本:类型断层

做 Web 开发这么久,我最大的体感是:无论是传统的模板渲染,还是现代的前后端分离,团队协作的大部分时间都耗在“对字段”上。后端接口返回{ username: 'foo', avatar_url: '...' },前端手写一个interface User { username: string; avatarUrl: string },运行时一看,下划线没了、字段拼错了,页面直接白屏。这种问题不出错则已,一出错就是联调阶段的深夜加班。

t3code 想解决的就是这个类型断层。它的思路不是引入一门新语言,而是把 TypeScript 从头到尾贯穿到全栈链路上:数据库表结构由 Prisma Schema 定义,类型通过prisma generate自动生成;服务端路由用 tRPC 声明,输入输出类型由 TypeScript 编译器推导;前端调用 API 时,不再有“URL 字符串 + 手动拼参数”的黑盒,而是像调用本地函数一样获得完整的入参提示和返回值类型。简单说,后端一旦改了字段,前端编译立刻报错,根本等不到运行期才踩坑。

我见过很多团队用 Swagger 维护接口文档,但文档是人类维护的,人类一定会漏。t3code 把文档换成类型系统本身,代码即文档。这个理念听起来很理想,实际跑起来之后,开发效率的提升是肉眼可见的:从“写完接口再写接口文档再让前端对照文档调试”变成“前后端同时看着类型写代码”,返工率下降了不是一点半点。

1.2 T3 Stack 为什么能成为团队默认选型

T3 Stack 是社区里一套比较成熟的全栈 TypeScript 技术栈,核心由 Next.js、TypeScript、Tailwind CSS、tRPC 组成,官方脚手架create-t3-app可以一条命令生成工程骨架。t3code 正是基于这套组合做的工程化封装,再加上 Prisma 做 ORM、NextAuth.js 做认证,形成一套能直接支撑业务落地的完整代码库。

选这套组合,我自己的理由有三个。

第一是技术栈心智负担低。Next.js 在 React 生态里几乎成了事实标准,App Router 同时支持服务端组件和客户端组件,页面、API、中间件放在一个项目里,不需要单独维护两个仓库,也不用为了 CORS 去配置一堆跨域规则。对于中小规模的团队,这套模式比微前端拆分要省心得多。

第二是类型系统的奇点效应。单看每个工具都不稀奇,TypeScript 谁都在用,tRPC 也有人单独接入,但它们组合在一起时,类型可以从数据库一路穿过服务端到前端,中间没有任何手工 bridge。这种“端到端类型安全”带来的不仅是编译期保障,更是 API 设计的自约束:路由的输入输出必须能被类型表达,反而促进了接口的定义更加严谨。

第三是不过度设计。create-t3-app在初始化时把可选项列得很清楚,你不需要 Prisma 可以不选,不需要 NextAuth 也可以不选,它不是那种绑死全家桶的框架。t3code 的定位是在这些可选项里挑一套默认配置,然后把环境变量、目录结构、错误处理、部署配置这些琐碎但关键的部分提前踩平,让新人拿到代码就能跑起来,而不是花一整天配环境。

2. 核心组件拆解与技术选型逻辑

2.1 Next.js 服务端与客户端边界

用 App Router 写 t3code 之后,最先要适应的是“服务端组件”和“客户端组件”的边界。很多人一开始会把所有组件都加上"use client",结果就是整个页面全部在客户端渲染,服务端组件的优势完全浪费。

这里有个简单的判断标准:如果组件里不需要useState、useEffect、事件监听,不需要调用浏览器 API,那就默认写成服务端组件。服务端组件里可以直接await操作,比如读取数据库、请求 API、读取文件,结果在服务端拼好 HTML 后直接下发,首屏性能和 SEO 都会好很多。而交互逻辑则下沉到子组件,比如一个点赞按钮、一个筛选表单,给这些叶子组件单独加"use client"就好了。

t3code 的目录设计把这种边界固化了下来:app目录下面是路由页面,以服务端组件为主;components目录里按server和client分子目录,组件职责一眼就能看清。实际开发时我会尽量避免从客户端组件里直接导入服务端组件,否则 Next.js 会报错,这也算一种强制约束——逼着你把数据获取和交互逻辑拆开。开始不习惯,习惯之后确实觉得代码结构干净了不少。

2.2 tRPC:把远程调用变成类型安全的函数调用

tRPC 的核心概念不复杂:你定义一堆 procedure 好像定义函数一样,前端通过一个由类型生成的 proxy 对象直接调用,网络请求被封装在框架内部。它不生成 RESTful 路径,也没有 GraphQL 那套 schema 语言,而是完全依赖 TypeScript 的infer能力,把服务端路由类型传播到客户端。

拿 t3code 里最常见的一个例子来说,后端写一个查询当前用户信息的 procedure:

import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; export const userRouter = createTRPCRouter({ me: protectedProcedure.query(async ({ ctx }) => { return ctx.db.user.findUnique({ where: { id: ctx.session.user.id }, select: { name: true, email: true, image: true }, }); }), });

前端调用的长这样:

import { api } from "~/utils/api"; export function UserBadge() { const { data, isLoading } = api.user.me.useQuery(); if (isLoading) return <span>加载用户...</span>; return ( <div> <img src={data?.image ?? "/fallback.png"} alt="avatar" /> <span>{data?.name}</span> </div> ); }

需要注意,上面api.user.me.useQuery()里的useQuery是 tRPC React Query 集成的产物。如果data可能为空,TS 会强制要求处理undefined,这就是意义的直接体现:写前端时鼠标悬停在data上,能看见完整的返回值结构,根本不担心字段名拼错。

不过 tRPC 也不是万能药。如果项目是纯移动端 App 暴露 API,或者有其他非 TypeScript 客户端消费接口,那 tRPC 的收益会大打折扣,因为它的类型传播依赖 TypeScript 编译器。t3code 更适合“同一个团队、同一个语言、前后端都在一个仓库或一个 monorepo 里”的场景。我们内部还有一个小服务是暴露给外部系统的,那个单独拆了 REST API,没有强行套 tRPC。

2.3 Prisma 数据建模与迁移

Prisma 在 t3code 里扮演的是数据库访问层的角色。我不会在这里展开所有 Prisma 功能,只说工程中最重要的三条:Schema 定义、迁移流程、类型生成。

Schema 定义是起点,它同时承担了“数据库表设计文档”的职责。例如一个简单的文章模型:

model Post { id String @id @default(cuid()) title String content String published Boolean @default(false) tags Tag[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Tag { id String @id @default(cuid()) name String @unique posts Post[] }

定义好之后跑npx prisma migrate dev,Prisma 会生成迁移 SQL 并同步更新数据库,同时通过prisma generate生成对应的 TypeScript 类型。这两步是连在一起的,但很多人容易漏掉第二步,导致 IDE 里ctx.db.post显示一堆红色波浪线。在 t3code 的package.json里,我会把postinstall脚本设置为prisma generate,这样每次pnpm install后都会自动同步类型,新同学拉代码后不用额外执行命令。

选择 Prisma 而不是直接写 SQL 或使用 TypeORM,除了类型生成这个理由之外,它的查询 API 也比较直观:嵌套查询一条include就能把关联模型带出来,懒加载的问题在 t3code 里基本不存在,因为所有查询都显式声明了select或include,没有隐式的 N+1 陷阱。

2.4 认证集成:NextAuth.js 的取舍

认证是全栈项目绕不开的环节。t3code 默认集成 NextAuth.js,主要看中它对 Next.js 的支持最好:Session 可以同时注入到服务端组件、API 路由和 tRPC context 里,配置不复杂就能支持 GitHub、Google 或邮箱登录。

实际配置时有一个关键操作:在 tRPC 的 context 生成逻辑里把getServerSession的结果挂上去,这样 protectedProcedure 可以直接从ctx.session拿到用户信息,而不需要在每个接口里重复读 session。t3code 里这套逻辑是封装好的,新增受保护接口只需要把publicProcedure换成protectedProcedure,剩下交给中间件判断。

当然 NextAuth 也有坑。比如 Credentials 登录在 TypeScript 类型上支持得不好,默认的 session 回调查询数据库还要额外配置。我的建议是:能用 OAuth 就用 OAuth,实在要自建账号密码体系,不如在 NextAuth 外面套一层自己写的登录逻辑,别为了框架的“全家桶”硬凑。取舍才是工程落地的常态。

3. 实操:从零搭建一套 t3code 全栈应用

3.1 五分钟跑通 create-t3-app 初始化

先说环境要求。t3code 基于 Next.js 14,Node.js 版本至少 18,包管理器我推荐 pnpm,磁盘空间吃紧的同学也可以用 npm,但 pnpm 的安装速度和依赖管理体验确实更好。下面的命令会创建一个名为 t3code 的项目:

pnpm create t3-app@latest t3code cd t3code pnpm install

脚手架会以交互方式询问你要启用哪些模块。我的默认选择是:Next.js、Tailwind CSS、tRPC、Prisma、NextAuth。如果你需要 eslint、prettier,也建议勾上,团队多人协作时代码风格统一能少很多无意义的争论。数据库字段先选 SQLite,本地跑通业务逻辑之后再切 PostgreSQL,这个流程对新手最友好。

初始化完成后,项目里会出现src/server/api/trpc.ts、src/server/api/root.ts、src/utils/api.ts这几个关键文件,它们是 tRPC 的“接线层”。新手经常改错root.ts没把新增的 router 注册进去,导致前端的api.xxx找不到对应方法,这个点后面排查部分还会专门讲。

3.2 定义数据模型并跑通数据库

初始化完 t3code 后用 SQLite 最直接。先打开prisma/schema.prisma,我把文件改成包含文章和标签两张表的版本,然后执行迁移:

npx prisma migrate dev --name init

执行完这一步,prisma/dev.db数据库文件就建好了,同时node_modules/.prisma/client里也生成了完整的类型定义。这时候在代码里写import { PrismaClient } from "@prisma/client",编辑器会给出完整的模型补全。

有个细节需要特别提示:Prisma 默认生成的 client 是在node_modules下的,而 t3code 的server/db.ts会导出一个单例的PrismaClient实例。在开发环境热更新时,如果不做单例缓存,每次保存代码都会重新创建数据库连接,开发模式下容易爆连接数。t3code 的模板里已做了全局变量缓存,这个设计建议保留,不要为省事把db导出改成直接new PrismaClient()。

3.3 编写第一个 tRPC 路由

数据库模型就绪后,开始写路由。我在src/server/api/routers/post.ts里定义了一个帖子路由,包含查询和创建两个 procedure:

import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; export const postRouter = createTRPCRouter({ list: publicProcedure.query(async ({ ctx }) => { return ctx.db.post.findMany({ orderBy: { createdAt: "desc" }, include: { tags: true }, }); }), create: publicProcedure .input( z.object({ title: z.string().min(1), content: z.string(), tags: z.array(z.string()).optional(), }) ) .mutation(async ({ ctx, input }) => { return ctx.db.post.create({ data: { title: input.title, content: input.content, tags: input.tags ? { connectOrCreate: input.tags.map((name) => ({ where: { name }, create: { name }, })), } : undefined, }, }); }), });

这里输入校验用 zod,和 tRPC 配合得非常流畅:如果调用方少传了title或传入的不是 string,前端调用会在发起请求前直接抛出 TypeScript 校验错误,不用等后端响应再猜。做完路由定义后,记得去root.ts里注册:

import { postRouter } from "./routers/post"; export const appRouter = createTRPCRouter({ post: postRouter, user: userRouter, });

注册这步不少人漏掉,漏掉的结果是前端api.post.list直接报 undefined。所以每次新增 router 第一件事就是去root.ts看一眼,形成肌肉记忆。

3.4 页面端调用与数据展示

页面端我用 App Router 的惯例,把数据获取放在服务端组件里,但 tRPC 官方推荐的是客户端组件里配合 React Query 使用。这两种模式没有绝对对错,我根据页面属性来选择:

  • 如果是公开的、偏展示型的页面,比如文章列表、作品集,我会用服务端组件直接调用appRouter.createCaller,把数据当成 props 传给子组件,这样首屏 HTML 里已经包含内容,有利于 SEO。
  • 如果是登录后的交互型页面,比如后台管理面板、表单提交,我会在客户端组件里用useQuery/useMutation,配合自动缓存、重试、失效重取,交互体验更顺滑。

客户端调用的示例非常简洁:

"use client"; import { api } from "~/utils/api"; export function CreatePostForm() { const utils = api.useUtils(); const createPost = api.post.create.useMutation({ onSuccess: async () => { await utils.post.list.invalidate(); }, }); return ( <form onSubmit={(e) => { e.preventDefault(); const formData = new FormData(e.currentTarget); createPost.mutate({ title: String(formData.get("title")), content: String(formData.get("content")), }); }} > <input name="title" placeholder="标题" required /> <textarea name="content" placeholder="正文" /> <button type="submit" disabled={createPost.isPending}> 发布 </button> </form> ); }

useUtils().post.list.invalidate()是 React Query 提供的缓存失效方法,它会在 mutation 成功后自动重新拉取列表数据,不用手动router.refresh(),也不用跳转页面强制刷新。这个体验比传统fetch后手动setState要自然得多。

4. 常见问题与排查技巧实录

4.1 环境变量读不到或类型缺失

t3code 项目里所有环境变量集中在.env文件,模板会生成.env.example作为参考。常见问题是复制了.env.example但没填完整,或者新加了一个环境变量却没同步到.env.example。

另外一个 Next.js 特有的坑:如果你在next.config.mjs里用到了某个环境变量,必须注意 Next.js 在构建时默认只暴露以NEXT_PUBLIC_开头的变量给客户端。在服务端代码里读正常,但在客户端组件里读就是 undefined。

我的建议是建立一套约定:凡是被 t3code 业务代码引用的变量,都先定义在src/env.mjs这个校验文件里,用 zod 做强校验。这样一旦环境变量缺失,启动时直接有红字报错,而不是运行到某个页面才发现功能异常,调试成本低很多。

4.2 Prisma 类型不同步

这是 t3code 开发群里问得最多的一个问题:改完 Schema 后前端报错,或者ctx.db上找不到刚加的字段。

绝大多数原因就是没有执行prisma generate。migrate dev会自动执行生成,但如果你手改了 Schema 后只保存文件,类型不会自动更新。快速自检方法是看node_modules/.prisma/client/index.d.ts的修改时间,如果晚于你的 Schema 修改时间就正常。

不放心的话,可以直接在项目根目录加一条命令:pnpm db:generate,内容就是prisma generate。每次 pull 代码后先跑一遍这个命令,再打开编辑器,能省掉很多莫名其妙的红波浪线。

4.3 tRPC 在非 Node 环境下的调用限制

t3code 主要面向 Next.js 全栈场景,所以我默认前端就是 Next.js。但如果你基于这套代码拆了一个 React Native 应用,或者有别的前端要消费同一套 tRPC API,你需要在别的项目里单独安装@trpc/client和@trpc/react-query,并且导入服务端AppRouter类型。

这一步坑很多,因为 AppRouter 类型是 t3code 服务端代码的一部分,跨项目共享需要把 router 做成独立的 npm 包,或者用 monorepo 的 workspace 引用。我们团队是直接把appRouter所在的包作为 workspace 依赖注入前端仓库的,但这样做会带来一个新的问题:前端仓库会连带安装一部分 Nest 依赖,打包体积变大。建议普通项目不要为了“极致类型安全”强行拆仓库,单仓模式下 tRPC 的体验才是最好的。

4.4 Vercel 部署时的 Prisma 连接配置

把 t3code 部署到 Vercel 时,Serverless 函数是无状态的,每个请求都可能新建数据库连接。如果直接使用 SQLite,服务端文件系统是只读的,所以生产环境必须切换到 PostgreSQL 或 MySQL 这类外部数据库。

切换到数据库后有两件事必须做。第一,在prisma/schema.prisma里把 provider 改成postgresql,并把连接字符串放到.env,例如:

DATABASE_URL="postgresql://user:password@host:5432/dbname?schema=public"

第二,给 Prisma Client 设置连接池参数。我用的连接串通常加上pgbouncer=true,同时在 Prisma 配置里限制连接数:

datasource db { provider = "postgresql" url = env("DATABASE_URL") directUrl = env("DIRECT_URL") }

这里DIRECT_URL用来绕过 pgBouncer 做迁移和后台任务,DATABASE_URL则走连接池。两个变量都要在 Vercel 后台的环境变量面板里配置。不配置的话,最常见的报错是P1001连不上数据库,或者是连接数超限。

部署时还会遇到一个问题:构建阶段 Prisma Client 还没生成,导致 Next.js 编译报错。我在package.json里加上构建脚本依赖:

"build": "prisma generate && next build"

这样就保证每次在 Vercel 上构建都会先执行类型生成,再编译应用。这个改动看起来不起眼,却是很多人部署失败的关键一步。

最后再分享一个小技巧:如果你用的是 D1、Neon 这类边缘数据库,可以在 Vercel 的vercel.json里设置函数区域和数据库实例同区域,减少跨区访问的延迟。实测下来,跨区域的冷启动连接时间能从几百毫秒降到一百毫秒以内,对接口响应速度的影响非常明显。

我个人做全栈项目已经有几年,t3code 这套代码让我最满意的地方,不是它用了多新的技术,而是类型系统真的参与到了开发流程里,把很多曾经在运行期才能发现的问题提前到了编译期。你不需要一上来就把整个项目切成微服务、定义几十个抽象接口,只要在现有 Next.js 工程里把 tRPC、Prisma 串起来,当天就能感受到字段提示、重构联动、错误校验带来的踏实感。如果你也想试试,建议先从一个小模块开始,比如做一个带登录和内容管理的内部工具,跑通一两个完整流程之后,你大概也会和我一样,再也回不去“手动对字段”的日子了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询