☰
T3 Stack全栈TypeScript实战:用tRPC和Prisma打造类型安全应用
2026/10/28 15:41:13 网站建设 项目流程

最近在搞一个内部工具平台,代号叫t3code。名字听起来挺玄乎,其实拆开就一句话:一个基于 T3 Stack 的全栈 TypeScript 项目样板,把 Next.js、tRPC、Prisma、Tailwind 四件套揉在一起,让你从头到尾只用 TypeScript 一种语言写完整条业务链路。

这个项目折腾下来我最大的感受是:类型不是约束,是最高效的沟通工具。以前写前后端,接口文档、类型对拍、联调排错,每一步都在消耗注意力。现在 tRPC 把 API 变成类型安全的函数调用,前端调后端像调本地方法一样,写错了编译期就炸给你看,根本不会带到浏览器里。这篇文章我就用t3code这个项目作为例子,把从技术选型到落地实施的完整过程拆开讲透,包括目录怎么组织、数据库模型怎么设计、tRPC 路由怎么划分、认证怎么做、有哪些绕不开的坑。适合正在学习全栈 TypeScript、打算用 Next.js 搭中大型项目、或者想把自己那套开发模板体系化的人。

1. 整体设计与技术选型思路

1.1 项目到底要解决什么问题

t3code说白了是我给自己团队用的一个代码片段管理平台,后来加上了团队分享和在线运行的功能。其实这种轮子网上多得是,为什么还要自己造一个?因为市面上现成的方案都存在同一个问题:要么数据存在别人服务器上,要么类型不通透,要么 UI 改起来跟做手术一样。

我的需求很简单:

  • 代码片段支持多语言语法高亮,按标签和项目维度分类。
  • 支持创建人和团队成员权限区分,成员可以看和用,只有创建人能改。
  • 有一个极简的在线运行入口,可以直接跑 JavaScript 和 Python 的短脚本。
  • 所有操作最好在同一个技术栈里搞定,不要前端一套、后端一套、数据库又一套。

这些需求拆解下来,技术选型的空间其实很大,但我最终选了 T3 Stack,没有别的原因,就是它把“类型安全”从 promise 变成了默认值。

T3 Stack 是 Theo 提出的全栈 TypeScript 组合,核心是:

组件职责为什么需要它
Next.js前端框架 + Server Side + API 路由App Router 天然支持 RSC 和服务端逻辑
tRPC前后端 API 调用层端到端类型安全,不需要 codegen
PrismaORM 和数据库迁移类型生成与 schema 同步,写起来最省心
Tailwind CSS样式方案原子类效率高,配合组件封装不容易踩样式坑

说句实在话,这个组合的强不在于某个单独工具,而在于每一层之间都有一层类型映射,Prisma 生成的类型可以直接用进 tRPC 的输入输出,tRPC 的返回类型可以直接被前端组件消费,整个链路中间没有“手写的 DTO”这种额外负担。

1.2 为什么选 tRPC 而不是 REST 或 GraphQL

写这个项目之前,团队里其实有过一波讨论,有的人倾向 REST + OpenAPI,有的人想直接上 GraphQL。最后我拍板用 tRPC,核心原因是这类项目 90% 的业务都是内部 API,不需要对外暴露。

如果你做的是开放平台、需要给第三方开发者调用,那 REST 带上 OpenAPI 文档是合理的。但像t3code这种团队内部系统,最大的痛点是前后端联调和类型同步,而 tRPC 用最朴素的方式解决了它:后端定义 router,前端引入该 router 的 type,直接调用。没有 codegen,没有 schema 文件,没有手写的请求体和响应体类型。

拿createSnippet这个 mutation 举例。后端写:

import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; export const snippetRouter = createTRPCRouter({ create: protectedProcedure .input( z.object({ title: z.string().min(1).max(100), language: z.enum(["typescript", "python", "javascript", "bash", "sql"]), code: z.string().min(1), tags: z.array(z.string()).default([]), projectId: z.string().optional(), }) ) .mutation(async ({ ctx, input }) => { return ctx.db.snippet.create({ data: { title: input.title, language: input.language, code: input.code, tags: input.tags, project: input.projectId ? { connect: { id: input.projectId } } : undefined, createdBy: { connect: { id: ctx.session.user.id } }, }, }); }), });

前端调用:

import { api } from "~/trpc/react"; const createSnippet = api.snippet.create.useMutation(); await createSnippet.mutateAsync({ title: "防抖函数", language: "typescript", code: "export function debounce(fn, delay = 300) { ... }", tags: ["utils"], });

ctx.session.user.id是服务端中间件注入的,前端根本碰不到,但返回的 snippet 对象类型却精确到字段级别。这就是端到端类型安全。

如果换成 REST,这种场景下你需要手写 ApiClient、Response 类型、错误处理,时间一长接口一多,必然有类型不同步的时候。用 GraphQL 也行,但为了一个内部系统引入 codegen 链路,我觉得得不偿失。

1.3 目录结构设计背后的思考

t3code的目录结构沿用了 T3 Stack 的标准约定,但我在团队项目基础上做了几个关键调整:

src/ app/ (auth)/ # 登录注册相关页面 (main)/ # 主界面布局 dashboard/ snippets/ projects/ api/ trpc/ [trpc]/route.ts # tRPC HTTP 入口 server/ api/ routers/ snippet.ts project.ts user.ts runner.ts root.ts # 汇总所有子路由 trpc.ts # createTRPCContext / createCallerFactory auth.ts # NextAuth 配置 db.ts # Prisma Client 实例 trpc/ react.tsx # React Query 封装 server.ts # Server Component 中调用 tRPC 时用 lib/ utils.ts # 通用工具函数

这里有个很多人一开始会忽略的设计点:src/trpc/react.tsx和src/trpc/server.ts必须分开。react.tsx里导出的是带 hooks 的 tRPC React Query 实例,server.ts里导出的是服务端 caller,两者都从同一个AppRouter类型推导,但运行时行为完全不同。你在 Server Component 里用api.snippet.list()直接调用,不用走 HTTP;在客户端组件里用useQuery走 HTTP 且带缓存、重试、失效等能力。

2. 核心开发流程与实操要点

2.1 从 create-t3-app 起步

创建项目是最没意思但也最容易埋坑的第一步。我用的是官方脚手架:

npx create-t3-app@latest t3code

交互式选择时,我建议勾上NextAuth、Prisma、Tailwind,先把 TypeScript 勾上这是默认的。ESLint和Prettier也建议直接要,因为后文代码格式问题会让 review 很痛苦。Docker 可以不选,但如果你不想在自己电脑上装 PostgreSQL,那就带上,一条docker compose up -d起来一个库特别省事。

这个脚手架出来的默认结构非常规整,但它有一个坑:把 tRPC 放在了src/server/api里,而 App Router 的入口放在src/app/api/trpc/[trpc]/route.ts。很多人第一次看会懵,其实这个[trpc]是 tRPC 的请求路由,所有/api/trpc/xxx.yyy的请求都会进到这个 handler 里。注意你不能随便改这个路径,否则客户端调用地址会对不上。

2.2 数据库模型设计

t3code的数据模型一开始很简单,我建了四张表:

model User { id String @id @default(cuid()) name String? email String @unique image String? snippets Snippet[] projects Project[] } model Project { id String @id @default(cuid()) name String createdAt DateTime @default(now()) members User[] snippets Snippet[] } model Snippet { id String @id @default(cuid()) title String language String code String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt tags String[] createdBy User @relation(fields: [createdById], references: [id]) createdById String project Project? @relation(fields: [projectId], references: [id]) projectId String? } model RunRecord { id String @id @default(cuid()) snippetId String? language String code String output String? status String createdAt DateTime @default(now()) userId String user User @relation(fields: [userId], references: [id]) }

这里我故意踩了一个 PostgreSQL 的坑:tags String[]在 SQLite 上不支持,所以必须用 PostgreSQL。如果你用 create-t3-app 时选了 SQLite,后面再用数组字段会直接迁移报错。T3 Stack 官方默认配置的是 PostgreSQL,这也是为什么我前面强调要用 Docker 起库。

设计模型时,我建议给所有关联字段加上索引,尤其是createdById和projectId,因为列表页几乎都会按这些字段做筛选。数据量不大时无所谓,但几百条之后就明显感觉查询变慢。迁移命令很简单:

npx prisma migrate dev --name init npx prisma generate

注意prisma generate必须在 schema 修改后执行,否则前端拿不到新的类型定义。很多“类型凭空消失”的报错,原因就是忘了跑这个命令。

2.3 tRPC 路由划分与 API 规范

路由划分这个事,我和团队反复调整过几次,最后形成了个人现在比较满意的规则:

  • 每个业务域一个文件,比如snippet.ts、project.ts、runner.ts。
  • 只暴露业务需要的操作,不要无脑写 CRUD。比如 snippet 就不提供listAll,只提供listMine和listByProject。
  • 所有输入用 zod 校验,不要偷懒只写z.any(),否则类型安全直接破功。
  • 返回类型尽量精简,不要直接把 Prisma 的完整对象丢过去,用select控制字段。

举个例子,列表页我只需要 title、language、tags、createdAt、authorName 几个字段,如果直接findMany()返回整个对象,前端会拿到一坨与渲染无关的数据,类型一多也是负担。所以我一般这样写:

listMine: protectedProcedure .input( z.object({ limit: z.number().min(1).max(50).default(10), cursor: z.string().optional(), }) ) .query(async ({ ctx, input }) => { const items = await ctx.db.snippet.findMany({ where: { createdById: ctx.session.user.id }, select: { id: true, title: true, language: true, tags: true, createdAt: true, createdBy: { select: { name: true, image: true }, }, }, orderBy: { createdAt: "desc" }, take: input.limit + 1, cursor: input.cursor ? { id: input.cursor } : undefined, }); let nextCursor: string | undefined; if (items.length > input.limit) { const nextItem = items.pop(); nextCursor = nextItem?.id; } return { items, nextCursor }; }),

cursor 分页是我个人比较喜欢的方案,比 offset 分页稳定,不会因为新插入数据导致重复或丢失数据。这里有个小细节:take要比 limit 多取一条,用来判断是否还有下一页,然后pop掉再返回。这个逻辑第一次写容易漏,漏了之后前端拿到多出来的数据会一脸懵。

2.4 前后端类型共享的“魔力”

t3code开发过程中最爽的时刻,就是你改了一个字段,整个前端类型检查马上告诉你哪里要改。这得益于 tRPC 的类型导出机制。

在src/trpc/react.tsx里有一行:

export const api = createTRPCReact<AppRouter>();

这个AppRouter是从rootRouter的类型里推导出来的,前端任何地方import { api } from "~/trpc/react"都能拿到完整的路由类型和输入输出类型。改后端,前端编译直接报错,根本不需要“记得去同步类型”这种心智负担。

但这里有个细节:Server Component 里不要用api.snippet.listMine.useQuery()。在服务端组件里你只能用api.snippet.listMine()这种直接调用方式,因为它不走 React Query,而是通过createCaller直接执行。如果混用,轻则拿到 undefined,重则因为缺少QueryClient而直接白屏。这个坑我已经帮大家踩过了。

3. 实操过程与核心环节实现

3.1 认证模块与中间件接入

认证我用的是 NextAuth,配置在src/server/auth.ts。这里有一个很多人忽略的模板流程:NextAuth 的配置选项在 create-t3-app 里默认是写在auth.ts里的,而src/app/api/auth/[...nextauth]/route.ts会引入它。你想加 GitHub 登录也好、Credentials 登录也好,都去改providers数组。

我为了内网使用方便,加了一个邮箱 + GitHub 双登录的方式。注意 NextAuth 的session.strategy我选择的是database,而不是默认的jwt。为什么呢?因为t3code里有少量用户角色字段需要持久化,放在 token 里每次读都很啰嗦,直接查库反而更干净。代价是每次请求都多一次数据库查询,但对这个体量完全无所谓。

中间件这一层,我不建议在 Next.js 中间件(middleware.ts)里做权限判断,因为中间件跑在 Edge 环境,拿不到 Prisma Client。更靠谱的做法是在 tRPC 的procedure层控制。protectedProcedure就是干这个的:

export const protectedProcedure = createTRPCProcedure() .use(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { // 这里可以注入额外字段 userId: ctx.session.user.id, }, }); });

用protectedProcedure定义的路由,天然携带ctx.userId,不需要每个 handler 里再检查 session。权限封装到这一层之后,业务代码非常干净。

3.2 tRPC 上下文与数据库实例管理

createTRPCContext的逻辑在src/server/api/trpc.ts里,T3 模板默认写法是从 NextAuth 拿 session,然后挂上db:

export const createTRPCContext = async (opts: { headers: Headers }) => { const session = await getServerAuthSession(); return { db, session, ...opts, }; };

这个db是src/server/db.ts里导出的单例 Prisma Client。为什么用单例而不是每次创建?因为 Prisma Client 底层有连接池和查询引擎,每次 new 会重复建立连接初始化,效率低还容易把连接数打满。模板里默认的写法就非常规范,你不用改。

有个小经验:如果你的服务跑在 serverless 环境(比如 Vercel),可能需要在db.ts里对 Prisma Client 做 global 缓存,避免热重载时反复初始化。T3 模板已经处理了这个问题,但如果你是自己手写的,千万别漏。

3.3 样式方案与页面骨架

Tailwind 这块我不是来科普原子化好处的,我要讲的是t3code里如何结合 shadcn/ui 高效搭建界面。shadcn 不是组件库,它是一堆可以直接拷进项目的带样式组件源代码,基于 Tailwind + Radix UI。用它的一个好处是完全可控,改样式直接改源码,没有包管理的黑盒。

以SnippetList组件为例,核心逻辑是拿到 snippet 列表然后渲染卡片。这里用 Tailwind 写起来非常迅捷:

<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> {snippets.map((s) => ( <div key={s.id} className="rounded-lg border border-gray-200 p-4 shadow-sm transition hover:shadow-md"> <div className="mb-2 flex items-center justify-between"> <span className="text-sm font-semibold">{s.title}</span> <span className="rounded bg-gray-100 px-2 py-1 text-xs">{s.language}</span> </div> <div className="flex flex-wrap gap-1"> {s.tags.map((tag) => ( <span key={tag} className="rounded-full bg-blue-50 px-2 py-0.5 text-xs text-blue-600"> #{tag} </span> ))} </div> </div> ))} </div>

这里有个实操细节:代码高亮我用的是react-syntax-highlighter,对于这种轻量场景已经完全够用,而且体积可控。如果你想更炫、支持更多语言,可以换shiki,但打包体积会明显涨。我的建议是:内部工具不折腾,react-syntax-highlighter足够。

3.4 在线运行子系统的实现

t3code里比较有意思的一个模块是“在线运行”,支持 JavaScript 和 Python。这里我想吹一下 Next.js 的 Route Handlers + Vercel 的云函数能力,但我最终没有直接用无头浏览器在本地跑代码,因为不可控又危险。运行任意用户提交的代码,安全边界很重要,所以我选择了一招:通过服务端路由把代码和一个简单的运行器通信。

其实这里的前端实现很简单,调用一个 tRPC mutation,把language和code传过去,后端再去执行。执行方式我用的是 Python 环境里常见的沙箱命令,不过这里面牵扯太多安全细节,这块我改天单独写一篇。简单说,t3code里的在线运行更多是一个演示功能,真实生产环境要考虑资源限制和网络隔离,不能在主应用进程里裸跑。

对于 JavaScript,可以用 Node 自带的vm模块做一个最小沙箱:

import { runInNewContext } from "node:vm"; import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "../trpc"; export const runnerRouter = createTRPCRouter({ runJavaScript: protectedProcedure .input( z.object({ code: z.string(), }) ) .mutation(({ input }) => { const sandbox = { console: { log: (...args: unknown[]) => (logs = args.map(String).join(" ")) }, }; let logs = ""; const context = Object.create(sandbox); runInNewContext(input.code, context, { timeout: 1000 }); return { output: logs }; }), });

一段话总结:在线执行尽量和主业务分离部署,这是我在t3code后续演进中最可能的改造方向。先把代码收进来、记录下来,再去异步执行,才是正规做法。

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

4.1 类型错误“代码幽灵”

t3code开发过程中我最常遇到的类型报错就是Property 'xxx' does not exist on type ...。这种错误 80% 的情况是你改了 Prisma schema,但忘了重新生成类型。另一个常见场景是 zod 的 input 类型和 Prisma 的输入类型不一致,比如tags字段我从String[]改成了Json,前端如果不同步修改,类型检查和运行时都会出问题。

排查技巧其实很朴素:所有类型层面搞不定的问题,先npx prisma generate,再清一下 Next.js 的.next缓存,然后重启 dev server。大多数“幽灵报错”都源自这三者。

4.2 数据库迁移冲突与重置

t3code迭代很快,基本上每天都要改 schema。有一次我改了字段名,但没写 down migration,直接migrate dev报错说迁移历史不干净。解决方法就是:

npx prisma migrate reset --force

这个命令会清空数据并重新执行所有 migration,所以只适合本地开发环境。生产环境一定要用真正的 migration 文件来升级,不要随便 reset。我这里有个血的教训:某次测试环境被我 reset 后,测试账号数据全没了,客户的 demo 也变成白板,十分狼狈。

我的做法是:本地开发随便 reset,测试环境用prisma db push代替 migrate,生产环境规规矩矩走migrate deploy。三个环境三条策略,各得其所。

4.3 tRPC 路由拉流报 404

如果你发现前端调用api.snippet.listMine()时报 404,或者提示No "GET" action,大概率是路径匹配的问题。tRPC 的 HTTP 路由格式是/api/trpc/snippet.listMine,如果你的route.ts文件名不对,或者rootRouter里没有正确挂载子路由,就会出现这种诡异情况。

我这里有一个排查顺序:

  1. 确认src/app/api/trpc/[trpc]/route.ts存在且内容正确。
  2. 确认root.ts里用snippet: snippetRouter注册了子路由。
  3. 确认服务端代码没有任何运行时异常导致程序直接抛错。

如果是自托管部署,还有可能和反向代理配置有关,注意不要把/api路径吞掉。

4.4 React Query 缓存导致页面“不刷新”

这是 tRPC + React Query 最常见的“玄学”问题。用户创建了一个 snippet,回到列表页,发现列表没有更新。原理很简单:React Query 的默认配置认为数据在staleTime内是新鲜的,不会重新请求。

解决方法有两种。一是创建成功后调用utils.snippet.listMine.invalidate():

const utils = api.useUtils(); const createSnippet = api.snippet.create.useMutation({ onSuccess: () => { utils.snippet.listMine.invalidate(); }, });

二是全局调低staleTime或者干脆设置refetchOnWindowFocus: true。我建议第一种,因为它更精确,不会产生无谓的网络请求。

4.5 Tailwind 类名不生效

Tailwind 的类名不生效,绝大多数时候不是 Tailwind 的问题,而是动态拼接类名的方式不对。你如果写:

const color = selected ? "bg-blue-500" : "bg-gray-500"; <div className={`... ${color}`}>

这是 OK 的。但如果你写:

<div className={dynamicClassMap[status]}>

其中dynamicClassMap里都是完整字符串,这也可以。怕的是你在类名中间用模板字符串拼“部分内容”,比如bg-${color}-500,Tailwind 的编译器根本扫描不到这种动态片段,构建产物里就不会有这个类。排查时先全局搜一下有没有这种写法,十有八九就是它。

5. 部署实践与项目复盘体会

5.1 部署到 Vercel 与自托管的选择

t3code最后是部署在 Vercel 上的,因为 Next.js 在 Vercel 上的体验最顺滑。但这里有一个隐藏问题:Vercel 的 serverless 函数有冷启动延迟,而且 Prisma Client 在 serverless 环境里需要额外的配置优化,比如binaryTargets设置。如果你的 schema 里没加:

generator client { provider = "prisma-client-js" binaryTargets = ["native", "rhel-openssl-1.0.x"] }

那在 Vercel 上部署时大概率会遇到PrismaClientInitializationError,因为 Prisma 引擎二进制和你服务器环境不匹配。

另一种选择是自托管,用 Docker 部署,一条docker build加docker run解决问题。对于内网工具来说,自托管可控性更强,但你需要自己处理环境变量、SSL、日志等运维事务。我的建议是:如果已经在用 Vercel 或者不介意冷启动,直接上 Vercel;如果这是公司内部系统而且有私有化需求,自托管。

5.2 性能优化实测记录

t3code在数据量达到数千条之后,列表页偶尔会出现明显卡顿。我用 React Query Devtools 看了一下网络请求,发现每次返回的数据量高达几十 KB,渲染时又要对 tags 做 map。优化手段有两步:

第一,用 cursor 分页限制单次返回条数,一次只取 20 条,滚动到底部的距离超过阈值后再请求下一页。这个方案把单次请求数据量从几十 KB 降到了几 KB。

第二,列表项用 React.memo 包裹,避免每次父组件状态变化时,所有卡片全部重新渲染。t3code里有一个点赞功能,点击一个卡片导致全部卡片重绘,这个问题在列表变大后非常明显。加 memo 后重新渲染范围被限制到那一项。

5.3 扩展方向与个人体会

目前t3code已经稳定运行了一段时间,我后续打算在这几个方向继续扩展。

一是加一个“建议片段”的能力,通过给每个 snippet 打上语言、用途等多个维度标签,再配合简单的统计得分来推荐给团队成员。这个功能技术上不难,但能直观提升工具的粘性。

二是引入 AI 辅助生成代码片段。现在调用大模型接口的成本已经足够低,完全可以在创建 snippet 时提供一个“帮你把旧代码整理成更好的写法”的按钮。这里注意控制好上下文长度和鉴权,不要让普通用户直接触碰模型密钥,所有调用统一走后端 procedure。

三是把 tRPC 的出入口接入审计日志。团队内部系统用久了,谁改了什么、删了什么,最好都能留痕,做合规审计。tRPC 的 middleware 天然适合做这件事,在protectedProcedure里同步写一条日志记录,不用侵入业务代码。

踩过这轮坑之后,我现在的看法是,选技术栈不要盲目追新,关键是每一层都能让你省心而不是费心。T3 Stack 最打动我的地方,就是它让“全栈类型安全”从口头禅变成了日常体验。t3code这个项目体量不大,但麻雀虽小五脏俱全,从认证、数据库、API 到部署运维都走了一遍,踩坑的经验也比较真实。如果你也在酝酿自己的全栈项目,或者想把手头这套脚手架流程化,不妨从 T3 Stack 开始,按照我上面的路线先搭建第一版,跑起来,再一点点迭代。过程中遇到的具体问题,欢迎带着报错信息来找我聊聊,很多问题看起来吓人,排查到最后都是些小细节。

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

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

立即咨询