☰
T3 Stack全栈开发实战:Next.js、tRPC与Prisma打造端到端类型安全应用
2026/10/26 9:12:17 网站建设 项目流程

前一阵子接手一个新项目,技术选型会议上大家又吵起来了:前端要类型安全,后端要开发速度快,数据层要省心,部署还要简单。折腾了一圈,我们最终决定用T3 Stack这套组合拳重新搭了项目——也就是t3code这个项目名字的由来。如果你也在纠结"到底用什么技术栈做全栈应用",或者想搞明白为什么Next.js、tRPC、Prisma、Tailwind、TypeScript这五个东西会经常被绑在一起说,这篇文章应该能帮你省不少时间。我会把从初始化、写业务、做鉴权到部署上线的完整链路都过一遍,顺带把我踩过的坑和最终沉淀下来的做法一起分享出来。

1. T3 Stack的构成与选型逻辑:为什么是这五件套

T3 Stack不是某个框架,而是一套被验证过的技术选型组合方案。t3code项目用的就是这套组合:Next.js承担页面渲染和路由,tRPC负责前后端接口通信,Prisma管数据库建模与迁移,Tailwind CSS处理样式,TypeScript把所有环节的类型串起来。刚接触的人会问,为什么不直接用Express配REST API,或者干脆上GraphQL?答案藏在"端到端类型安全"这六个字里。

1.1 端到端类型安全到底意味着什么

传统的前后端分离架构里,后端写一个接口返回JSON,前端拿到数据后要自己定义interface去手动对齐。字段名拼错了、类型不匹配了、后端改了字段前端没同步,这些问题只能在运行时暴露。T3 Stack的思路是:既然前后端都写TypeScript,为什么不共享类型?tRPC做到了这一点——你在后端路由里定义的查询和期望的返回类型,前端导入后直接被TypeScript推导出来,写完调用代码的同时,IDE里面就已经知道返回了什么字段、类型是什么、参数缺了哪个。这不是"少写几行类型定义"这么简单,它把一类错误从运行期提前到了编译期。

1.2 每个成员为什么不可替代

我把这五件套拆开分析过,每个都不是随便选的。

Next.js自不必说,它是目前React生态里最成熟的全栈框架,既能做SSR也能做静态导出,API Routes可以直接充当tRPC的服务端宿主。更重要的是,Vercel对Next.js的部署支持几乎做到了零配置,这对小团队和独立开发者非常友好。

tRPC的核心价值不在于它"没有schema定义",而在于它让前后端共享类型变成了原生能力。用tRPC写接口不需要生成客户端代码,不需要运行时校验的schema,路由定义本身就是API文档。相比之下,REST需要维护OpenAPI规范,GraphQL需要写SDL和resolver映射,tRPC把这三层压缩成了一层。代价是它绑定TypeScript生态,但既然项目已经选了TS,这个代价就不存在。

Prisma是我用过的ORM里最能打的类型体验。你在schema.prisma里定义好模型,跑一次prisma generate就能获得完整的、带类型推断的查询客户端。写prisma.user.findUnique时,IDE会自动提示所有字段。它的迁移系统也做得扎实,prisma migrate dev会对比schema生成迁移文件,加字段、改索引、建关联都走同一条流水线,不会出现在团队协作时"我改了数据库但你那边不知道"的情况。

Tailwind CSS被纳入T3 Stack起初让我有点意外。后来理解到,它的作用不是"好看的样式库",而是消除"样式与组件不同步"的问题。用Tailwind意味着设计变量(颜色、间距、字体、断点)都在配置里统一管理,组件代码里直接用对应的工具类,改主题只改配置文件就行。对于以功能性为主的应用来说,这套方案比维护一套CSS文件系统靠谱得多。

TypeScript则扮演了胶水角色。没有TS,tRPC就是空中楼阁,Prisma的类型生成也发挥不了作用。有了TS,前面几个工具的能力才会被真正释放。

2. 初始化t3code:create-t3-app创建项目的完整流程

T3 Stack官方提供了一个脚手架叫create-t3-app,一行命令就能创建带全部配置的项目。我创建t3code时执行的就是这个命令:

npm create t3-app@latest t3code

执行之后它会问你选哪些模块。t3code项目我选了NextAuth、Prisma、Tailwind、tRPC。这一版脚手架选项比以前精简了不少,但核心的四个可选项都在。还有一个比较关键的选择是Next.js的App Router还是Pages Router——现在新版本默认推荐App Router,除非你有特定原因(比如老依赖不支持App Router),否则直接用App Router。

2.1 项目生成后的目录结构拆解

初始化完成后的目录结构值得认真看一遍,它和传统Next.js项目的最大区别是多了一层src/server和src/trpc的隔离。

t3code/ prisma/ schema.prisma src/ components/ app/ api/ auth/ [...nextauth]/ layout.tsx page.tsx server/ api/ root.ts routers/ post.ts user.ts auth.ts db.ts trpc/ server.ts client.ts react.tsx .env

src/server/db.ts负责初始化Prisma客户端,src/server/auth.ts封装NextAuth配置,src/trpc下是tRPC的server和client入口。这样的结构把服务端能力与页面组件彻底分开了,避免出现"谁都能调数据库"的混乱局面。

2.2 环境变量与配置文件的前期设置

t3code项目生成后第一件事是配置.env。playground模板会给一个.env.example,里面包含DATABASE_URL和NEXTAUTH_SECRET等关键变量。记得先把这些变量填好,否则Prisma客户端初始化时会直接报错。

DATABASE_URL我用的PostgreSQL,连接串格式大概是:

postgresql://username:password@localhost:5432/t3code?schema=public

如果你本地还没装PostgreSQL,先用Docker起一个是最省事的:

docker run --name t3code-db -e POSTGRES_PASSWORD=devpass -e POSTGRES_DB=t3code -p 5432:5432 -d postgres:16

然后修改schema.prisma里的provider为postgresql,执行:

npx prisma migrate dev --name init

这一步会在数据库里创建实际表结构,同时生成Prisma Client。生成客户端这步往往会被人忽略——改完schema不执行generate,TS类型不会更新,IDE里会一直飘红。

3. 从数据库到接口再到页面:一条完整的类型安全链路

这一章是整个T3 Stack使用体验的核心。我以t3code项目里最常用的"文章列表"功能为例,展示数据是怎么从PostgreSQL一路带着类型信息流到React组件里的。

3.1 先定义Prisma模型,跑迁移和生成

假设我们做一个极简的文章系统,模型定义如下:

model Post { id String @id @default(cuid()) title String content String published Boolean @default(false) author User @relation(fields: [authorId], references: [id]) authorId String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model User { id String @id @default(cuid()) name String? email String @unique posts Post[] }

修改完后执行:

npx prisma migrate dev --name add_post_model

迁移成功后,node_modules/.prisma/client里会生成对应的类型定义。这时候你在src/server/db.ts里调用db.post.findMany()返回的结果就已经带了类型。

3.2 在tRPC路由器里暴露查询与写入方法

接下来我们在src/server/api/routers/post.ts里写业务逻辑。tRPC路由的定义方式很直观,createTRPCRouter里放一组procedure,每个procedure就是一个可调用的接口函数:

import { createTRPCRouter, publicProcedure, protectedProcedure } from "~/server/api/trpc"; import { z } from "zod"; export const postRouter = createTRPCRouter({ listPublished: publicProcedure.query(({ ctx }) => { return ctx.db.post.findMany({ where: { published: true }, orderBy: { createdAt: "desc" }, include: { author: { select: { name: true } } }, }); }), create: protectedProcedure .input(z.object({ title: z.string().min(1), content: z.string().min(1), })) .mutation(({ ctx, input }) => { return ctx.db.post.create({ data: { title: input.title, content: input.content, authorId: ctx.session.user.id, }, }); }), });

注意两个细节。第一,publicProcedure允许未登录用户访问,protectedProcedure则要求必须有session,否则会被拦截。第二,tRPC和zod集成得很好,输入校验直接用zod schema声明,前端调用时如果参数不符合规范,类型系统会直接提示。这里ctx.db是从tRPC上下文注入的Prisma实例,ctx.session则是NextAuth注入的用户会话。

3.3 前端React Query调用:类型自动推导

在页面组件里调用tRPC时,类型推导就变得非常直观了。我们这段代码位于一个"use client"组件里:

"use client"; import { api } from "~/trpc/react"; export function PostList() { const { data: posts, isLoading, refetch } = api.post.listPublished.useQuery(); const createPost = api.post.create.useMutation({ onSuccess: () => refetch(), }); if (isLoading) return <div>加载中...</div>; return ( <ul> {posts?.map((post) => ( <li key={post.id}> <h3>{post.title}</h3> <p>作者:{post.author.name}</p> </li> ))} </ul> ); }

这段代码里,posts已经自动被推断为{ id: string; title: string; content: string; published: boolean; createdAt: Date; updatedAt: Date; author: { name: string | null } }[]这样的结构。你写错属性名IDE直接报错,根本不会让你把这个错误带到浏览器里。createPost的入参也被derive成{ title: string; content: string },少传字段编译不过去。

这条路跑顺了之后,团队里新同事上手很快:数据库、API、UI三层不再需要互相翻阅文档,类型本身就是最好的文档。

4. 用NextAuth做登录鉴权,并把用户身份注入tRPC上下文

大部分全栈应用躲不开用户体系这一步。t3code用NextAuth处理认证,配合tRPC需要一个关键桥梁:把session对象塞进tRPC的context里,让protectedProcedure能直接拿到当前登录用户。

4.1 NextAuth的基础配置

NextAuth在T3模板里已经预置好了部分配置文件,路径在src/server/auth.ts。我用的CredentialsProvider——允许用户用邮箱加密码登录。生产环境的密码一定要用bcrypt或者argon2做哈希,不能存明文。配置片段如下:

import { getServerSession, type NextAuthOptions } from "next-auth"; import Credentials from "next-auth/providers/credentials"; import { compare } from "bcryptjs"; import { db } from "~/server/db"; export const authOptions: NextAuthOptions = { session: { strategy: "jwt" }, providers: [ Credentials({ credentials: { email: { label: "邮箱", type: "email" }, password: { label: "密码", type: "password" }, }, async authorize(credentials) { const user = await db.user.findUnique({ where: { email: credentials?.email }, }); if (!user?.passwordHash) return null; const ok = await compare(credentials?.password ?? "", user.passwordHash); if (!ok) return null; return { id: user.id, email: user.email, name: user.name }; }, }), ], callbacks: { async jwt({ token, user }) { if (user) token.id = user.id; return token; }, async session({ session, token }) { if (session.user) session.user.id = token.id as string; return session; }, }, }; export const getServerAuthSession = () => getServerSession(authOptions);

这里有个关键点:CredentialsProvider用JWT策略时,user.id不会自动出现在session里,必须通过jwt和session两个callback传进去。我之前漏掉第二步,导致protectedProcedure里一直拿不到用户ID。

4.2 tRPC上下文如何拿到session

模板里的src/server/api/trpc.ts已经帮我们把getServerAuthSession和db注入到context里了:

import { initTRPC, TRPCError } from "@trpc/server"; import { type CreateNextContextOptions } from "@trpc/server/adapters/next"; import { getServerAuthSession } from "~/server/auth"; import { db } from "~/server/db"; export const createTRPCContext = async (opts: CreateNextContextOptions) => { const session = await getServerAuthSession(); return { db, session, ...opts, }; };

然后protectedProcedure就是基于publicProcedure加了一层session检查:

const t = initTRPC.context<typeof createTRPCContext>().create(); export const protectedProcedure = t.procedure.use(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { ...ctx, session: { ...ctx.session, user: ctx.session.user } }, }); });

这段代码是T3模板自带的,重点理解这个模式:中间件抛错,请求被拦截,后续逻辑不执行。以后如果你想做角色权限控制,照葫芦画瓢再包一层判断就行。

4.3 前端登录流程与受保护页面的配合

前端登录时,直接调用NextAuth提供的API即可。比如src/app/login/page.tsx里写一个表单,提交到/api/auth/callback/credentials,成功后调用router.refresh()刷新服务端session状态。

受保护页面有两种常见做法。一是用Next.js的middleware,在src/middleware.ts里拦截未登录用户:

export { default } from "next-auth/middleware"; export const config = { matcher: ["/dashboard/:path*"], };

二是页面内用getServerAuthSession做服务端判断并重定向。我倾向于后者,因为更灵活,可以在跳转前把redirect参数带上下文。比如:

import { redirect } from "next/navigation"; import { getServerAuthSession } from "~/server/auth"; export default async function DashboardPage() { const session = await getServerAuthSession(); if (!session?.user) redirect("/login"); return <div>欢迎,{session.user.name}</div>; }

简单粗暴,对自助式应用够用了。

5. 部署到生产环境:Vercel配PostgreSQL的实操流程

项目开发完总要上线。t3code部署我选择Vercel加Neon(或Supabase)的PostgreSQL托管服务。整个部署链路里最有迷惑性的不是"怎么部署",而是"数据库迁移在谁手里、什么时候跑"。

5.1 Prisma迁移在Serverless环境里的正确姿势

在本地跑prisma migrate dev会自动改名、执行迁移、生成客户端,但在生产环境不能这么做。生产环境更稳妥的方式是把迁移文件带上,部署前显式执行prisma migrate deploy。

Vercel的构建流程里有一个Build Command,我设置为:

prisma generate && prisma migrate deploy && next build

这个顺序很重要:先generate,让Prisma Client带着最新的schema类型信息参与Next.js构建;然后migrate deploy,把迁移应用到生产数据库;最后才构建Next.js应用。如果顺序反了,构建阶段可能找不到对应的Prisma类型定义。

5.2 环境变量的管理与常见坑

Vercel项目设置里的Environment Variables必须和本地的.env对齐,至少需要:

  • DATABASE_URL:生产库连接串
  • NEXTAUTH_SECRET:一个足够长的随机密钥
  • NEXTAUTH_URL:生产域名

比较隐秘的一个坑是:PostgreSQL托管服务一般会提供直连和连接池两个地址。在Serverless环境下,不建议直接用直连地址作为唯一的DATABASE_URL,因为连接数会迅速打满。我的方案是DATABASE_URL设为连接池地址(如Supabase的Transaction Mode连接串),在Prisma配置里设置较低的连接数限制。这样能明显减少连接泄漏导致的超时问题。

5.3 构建失败排查的典型路径

如果Vercel构建卡住或者报错,按这个顺序排查最有效:

  1. 先看Build Log里Prisma相关的输出。常见的是Environment variables not found,基本就是环境变量没填全。
  2. 其次是PrismaClientInitializationError。这时候先确认数据库地址能从Vercel的网络访问,有些托管服务需要开启公网访问或者加白名单。
  3. 再看Next.js构建阶段有没有类型错误。T3 Stack是强类型体系,next build会执行tsc检查,本地能过不代表线上的环境变量、数据库状态能让你过。有些字段在开发库里存在但生产库里还没有,就可能报类型检查失败。

我踩过的一个真实坑:生产库的Post模型还没跑迁移,但代码里已经在查post.author.name,next build阶段Prisma类型检查通过不了。后来我把迁移和构建顺序调整好,才解决了这个问题。

6. 性能优化和缓存策略:Server Components加载tRPC数据的进阶玩法

跑通基础功能只是第一步。t3code上线后遇到的问题是首屏请求过多:页面渲染时前端组件会发起tRPC请求,然后再加载数据,多了一次客户端到服务端的往返。这里可以用T3 Stack结合Next.js App Router的特有方式做优化。

6.1 在Server Component里直接调用tRPC

App Router的Server Component可以直接调用tRPC的query,而不需要先渲染Client Component再发起请求。方法是用模板生成的src/trpc/server.ts的api对象:

import { api } from "~/trpc/server"; export default async function HomePage() { const posts = await api.post.listPublished(); // 这里拿到的posts已带完整类型 return <PostList initialPosts={posts} />; }

api.post.listPublished()在服务端直接执行,减少了浏览器端的请求时延。然后把数据作为props传给客户端组件做渲染。如果你用了React Query,只需要在客户端初始化时把initialData作为预置缓存,后续更新依然走useQuery的机制。

6.2 合理使用React Query的缓存策略

tRPC Client组件默认套了React Query的缓存。意味着同一个查询在页面内多处使用时,不会重复请求。实际操作时的经验是:频繁更新的数据,把staleTime调低,比如文章列表设置成5秒或10秒;几乎不变的数据,比如用户资料、配置项,可以设置成5分钟甚至半小时。示例:

const { data } = api.user.getProfile.useQuery(undefined, { staleTime: 5 * 60 * 1000, });

6.3 避免N+1查询和过度include

tRPC + Prisma很容易写出性能差的查询,比如在列表循环里对每个元素单独联查关联用户。Prisma的include会自动生成join联查,但用的时候要克制。一次findMany查出几百条记录,每条都include两级关联,返回的JSON体积和数据库压力都会显著上升。

我习惯做法是:列表页尽量只查当前层级需要的数据,作者名字等次要信息用select限定字段,避免整行扫描。对于Dashboard这种需要聚合的场景,直接在Prisma的aggregate或groupBy里计算,不要先把明细拉到Node层再手动统计。

7. 这块骨头啃下来之后:一些实际体会

t3code这个项目跑下来,我的感受是:T3 Stack把"写全栈应用"这件事从"拼接多个工具"变成了"使用一套有共识的体系"。你不太需要在路由方案、API风格、ORM选择、样式策略上反复纠结,官方模板已经替你想清楚了。好处是起步快、类型安全贯穿始终;需要适应的点是你得接受它的理念——比如tRPC绑定TS生态、Prisma抽象了SQL细节、Tailwind的class在HTML里看起来不太传统。

如果你是从零开始尝试,我的建议是别一开始就追求完美架构,先照着模板把注册登录、列表详情这条最简单的链路跑通。等你习惯了"改一个数据库字段,一周后IDE还在提醒你哪里没改"这种安全感之后,大概率就回不去了。后面你再根据自己的领域,把文件上传、后台任务、定时通知加进去,T3 Stack的插件生态和Next.js周边工具都能接得上。

个人最后补一句:生产环境里一定要盯住数据库连接池和Prisma迁移这两个环节。它们平时不起眼,出问题时恰恰都是最扎心的服务中断源头。把这两件事在部署流水线里固化好,你就能睡个安稳觉。

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

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

立即咨询