☰
T3 Stack全栈项目实战:基于t3code模板的完整实现与避坑指南
2026/10/27 16:25:01 网站建设 项目流程

自己搭项目这两年,我越来越觉得前端工程化领域不缺新框架,缺的是能把整套技术栈捏合在一起、开箱即用的东西。去年我在一个中小型产品上尝试了 T3 Stack,把 Next.js、tRPC、Prisma、Tailwind 和 NextAuth 全部串起来,前后端类型完全打通,迭代效率有了质的提升。后来我把这套骨架整理成了一个内部叫 t3code 的开源模板,陆陆续续用它生成了几个实际线上项目。这篇文章就是我整理 t3code 时的完整思路和实操记录,包括为什么选这条路线、每个核心模块怎么落地、踩过哪些坑,以及最终我怎么把它沉淀成一套可复用的标准化流程。

如果你正在纠结全栈项目怎么选型,或者已经听说 T3 Stack 但不确定它适不适合自己,这篇文章应该能帮你省下不少试错时间。我会从设计动机讲到具体代码,再到部署细节,尽量把能直接照抄的部分都写出来。

1. 项目定位与整体设计思路

1.1 为什么是 T3 Stack,而不是传统的"前端框架 + 独立后端"?

先说结论:这套技术组合解决的最大问题,是前后端类型割裂导致的隐性沟通成本。传统模式下,后端定义接口文档,前端自己维护一套类型定义,两个文件稍微不同步,联调阶段就会浪费大量时间。t3code 选择 T3 Stack,核心就是用 tRPC 把 API 层变成一种"编译器强约束"的调用:前端在router上定义了哪些 procedure,前端就能直接以类型安全的方式调用它,字段错了、类型变了,编辑器里当场报错,根本走不到联调这一步。

我见过太多团队把大量精力花在维护 Swagger 文档和手写 axios 类型上,其实这些都是重复劳动。T3 Stack 不是最花哨的方案,但它把"类型安全从数据库一路穿透到 UI"这件事做得非常彻底,这刚好契合中小型产品希望快速迭代、又不想牺牲质量的需求。t3code 能把这块的自动化程度再拉高,是因为我把项目结构、依赖版本、eslint 规则、环境变量管理全部封装成了模板,新业务进来只需要关注自己的表结构和业务逻辑。

1.2 t3code 到底解决什么问题

create-t3-app官方脚手架解决了"快速起项目"的问题,但真正业务落地时,你会发现脚手架生成的只是骨架,少了以下几层东西:

  • 目录分层规范:业务模块、公共组件、服务端逻辑、数据库模型应该怎么分,脚手架没告诉你。
  • 权限体系的完整封装:登录、注册、会话刷新、角色校验这些逻辑,每个项目都要重写一遍。
  • 多租户和软删除这类常见需求:没有现成模式,容易写到一半发现设计错了。
  • 部署时的环境配置差异:本地、测试、生产三套环境,配置项容易漏。

我做 t3code 的初衷,就是把这些"脚手架没管、但又每个项目都需要"的部分补齐。它不是一个框架,而是一个带最佳实践的项目模板。所有业务代码都按领域模块拆分,新增一个功能时,从一个最小可运行样例复制改起,两个小时就能上线一个完整的 CRUD 接口加页面。

1.3 技术选型中的几个关键取舍

在具体选型时,我有过几轮犹豫。第一点是Next.js 版本,最终用了 App Router 而不是 Pages Router。虽然 Pages Router 更成熟,但 App Router 的 Server Components、布局嵌套、服务端数据获取模式更适合中后台项目的组织结构,tRPC 也提供了配套的 server 端调用方式。第二点是Prisma vs Drizzle,Drizzle 更轻量、SQL 控制力更强,但 Prisma 的 migration 流程、类型生成和生态成熟度在团队协作时更稳,尤其对非核心 DBA 的开发者更友好。第三点是鉴权方案,没有自研 session 系统,而是直接用 NextAuth,因为它覆盖了 GitHub、Google、企业微信等常见 OAuth 供应商,能省很多适配时间。

这轮取舍之后,t3code 的技术栈固定为:Next.js App Router + TypeScript + tRPC + Prisma + PostgreSQL + NextAuth + TailwindCSS。没有什么新奇的黑科技,但每一层都经过实际项目验证,属于"稳定组合拳"。

2. 五个核心模块的实现细节

2.1 Next.js App Router:以目录结构驱动业务模块

在 t3code 里,App Router 不只是路由工具,我把整个前端模块组织都押注在它的目录约定上。每个业务域在src/app下拥有独立文件夹,内部包含page.tsx、_components/、_hooks/、_api/,业务相关的 UI 组件和逻辑都收拢在同一个域里。这样在多人协作时,每个人改自己的模块,基本不会产生冲突。

一个典型的业务目录结构长这样:

src/app/ ├── dashboard/ │ ├── page.tsx │ ├── layout.tsx │ ├── _components/ │ │ ├── StatsCard.tsx │ │ └── DataTable.tsx │ ├── _hooks/ │ │ └── useDashboardData.ts │ └── _api/ │ └── stats.ts

要注意的是 App Router 的layout.tsx嵌套特性非常有用。t3code 在顶层 layout 里挂全局 Provider,在(auth)分组里挂登录拦截逻辑,在(dashboard)分组里挂侧边栏和顶部导航。不同区域的布局互不干扰,权限拦截的层级关系也一目了然。

一个容易忽略的坑:page.tsx默认是服务端组件,不能直接用 hooks。t3code 的解决方案是页面级组件保持服务端渲染,在需要交互的位置引入客户端子组件。这个界限如果模糊了,轻则白屏慢,重则出现 hydration 报错。我见过不少新手直接把useEffect写在 page 里,结果跑起来报useState is not defined的错,这就是把服务端组件当客户端组件用的典型症状。

2.2 tRPC:让类型安全真正贯穿数据库到 UI

tRPC 是 t3code 的核心灵魂。它不生成 REST 路由,而是像"本地函数调用"一样在服务端定义 procedure,前端直接调用同名函数。类型信息通过 TypeScript 的InferRouterOutputs自动推导,数据库查询的返回结果长什么样,前端拿到数据的类型就是什么样,不需要中间人。

在 t3code 里,我把 tRPC 拆分成了三层:

src/server/ ├── api/ │ ├── routers/ │ │ ├── post.ts │ │ ├── user.ts │ │ └── _app.ts │ ├── procedures.ts │ └── trpc.ts

procedures.ts是我重点设计的地方,它对原生protectedProcedure做了一层封装,默认注入当前登录用户信息。这样业务路由里只需要写:

import { protectedProcedure } from "../trpc"; export const postRouter = router({ listMine: protectedProcedure .input(z.object({ page: z.number().default(1) })) .query(async ({ ctx }) => { const posts = await prisma.post.findMany({ where: { authorId: ctx.user.id }, orderBy: { createdAt: "desc" }, }); return posts; }), });

前端调用时几乎不用关心 HTTP 细节,IDE 自动补全就能给出入参和返回类型。这种体验一旦习惯了,再回去写手维护 type 的 REST API,会感觉效率断崖式下跌。

重要的性能提示:很多人把大量耗时逻辑写进 tRPC procedure 而不做超时控制。默认情况下 tRPC 的 HTTP 请求走的是 fetch 的超时策略,没有专门配置时可能长时间挂起。我在 t3code 里给所有 mutation 添加了超时保护,建议把重型逻辑丢到异步队列,立即返回 jobId,前端轮询查询状态,避免用户等待超时。

2.3 Prisma 数据模型:schema 即数据库设计的单一事实源

Prisma 的优势在于把数据库表结构、迁移脚本和类型定义三者统一。t3code 的prisma/schema.prisma里内置了几套我反复用到的模式:用户、账户、会话、验证码、基础业务表。这么做的价值在于,新项目初始化时不需要从零建模,直接基于 schema 改字段即可。

比如用户表,t3code 长这样:

model User { id String @id @default(cuid()) email String? @unique name String? passwordHash String? image String? role Role @default(USER) posts Post[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([email]) } enum Role { USER ADMIN }

Prisma 的迁移在项目早期非常顺畅,但注意一个细节:生产环境的 migration 不要随手db push,一定要走prisma migrate deploy。db push适合本地开发阶段快速同步,生产环境应该用版本化迁移文件,否则多人协作时很容易出现"我的本地数据库字段比你的多一个",合并后部署直接崩掉。

t3code 还内置了一个小工具函数,用于生成分页查询的 where 条件,避免每个列表接口都重复写skip/take/orderBy三件套。虽然逻辑不复杂,但这类样板代码最容易在赶工时出错,封装一层更保险。

2.4 NextAuth:认证和权限它们不是一回事

很多项目把"登录"和"权限"混为一谈,这是个大坑。登录解决的是"你是谁",权限解决的是"你能干什么"。NextAuth 的session回调里可以拿到用户 ID 和角色,但如果你只依赖 session 里的字段做页面级权限判断,服务端数据接口就完全没有安全保障。

t3code 的做法是两层配合:

  1. 页面级:在布局层判断 session 是否存在,没有就重定向到登录页。
  2. 接口级:tRPC 的protectedProcedure不仅要求登录,还会在 procedure 内部做角色校验。
export const adminProcedure = protectedProcedure.use(({ ctx, next }) => { if (ctx.user.role !== "ADMIN") { throw new TRPCError({ code: "FORBIDDEN" }); } return next({ ctx }); });

这套写法的好处是,权限校验跟业务逻辑完全解耦。你想限制某个接口只让管理员调用,把protectedProcedure换成adminProcedure就行,不用在接口内部散落各种 if 判断。

NextAuth 的 OAuth 配置还有几个容易踩的细节,后面在最后一节统一说,这里先记住一个原则:任何 handle 回调里产生的数据变更,都要做幂等处理。OAuth 用户模型和教育系统里的学生,会在不同环境重复回调,不幂等容易产生重复数据。

2.5 TailwindCSS 与主题体系

t3code 在样式上直接用 Tailwind 默认的设计令牌,没有引入组件库。原因很简单,组件库虽然上手快,但二开到一定程度会被它的样式体系绑架,改主题时非常痛苦。Tailwind 配合@shadcn/ui的风格,既保证了开发速度,又保留了完全的自定义空间。

我在模板里预先定义好了品牌色、圆角、间距的 CSS 变量:

@theme { --color-brand-50: #eef2ff; --color-brand-100: #e0e7ff; --color-brand-500: #6366f1; --color-brand-700: #4338ca; }

这样整个项目的视觉风格都源自这几个变量,暗色模式也不需要写一堆dark:前缀,直接通过变量切换。说实话,Tailwind 的生态已经足够成熟,哪怕你用tailwindcss-animate这类扩展做交互动画,也没必要引入重型 UI 库。

3. 从零到一:完整实操记录

3.1 初始化与依赖准备

如果你要从头构建一个类似 t3code 的项目,最常用的命令依然是官方脚手架:

npx create-t3-app@latest my-app

交互式选择时,勾上 Next.js、Tailwind、tRPC、Prisma、NextAuth,数据库选 PostgreSQL。这里有个经验分享:数据库引擎一定不要用 SQLite 起步,虽然 SQLite 部署零成本,但并发写入和数据类型约束在生产环境中撑不了多久,等业务起来再换 PostgreSQL,迁移成本远超一开始就配好的那半小时。

初始化完成后,把关键依赖统一升级到最新稳定版,然后安装几个 t3code 常备的辅助库,比如zod做输入校验、date-fns处理日期、superjson做序列化。注意,tRPC 的序列化默认依赖 superjson,它能自动处理 Date、Map 等特殊类型,如果忘了配这个,查询出来的日期字段会变成字符串。

3.2 核心配置逐项讲解

这是整个模板里技术含量最高的一步,拆分来看:

第一个是环境变量管理。t3code 把所有环境校验集中到src/env.js,在应用启动前就用 zod 校验,跑不起来总比上线后才发现缺失强。.env.example里我列全了所有需要配置的项,团队成员新入职时复制一份再填自己的本地值,十分钟就能跑起来。

DATABASE_URL="postgresql://user:password@localhost:5432/t3code" NEXTAUTH_SECRET="local-development-secret" NEXTAUTH_URL="http://localhost:3000" GITHUB_CLIENT_ID="" GITHUB_CLIENT_SECRET=""

第二个是tRPC Router 的挂载方式。在 Next.js App Router 下,推荐写法是在src/app/api/trpc/[trpc]/route.ts里创建 handler:

import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; import { appRouter } from "@/server/api/root"; import { createTRPCContext } from "@/server/api/trpc"; const handler = (req: Request) => fetchRequestHandler({ endpoint: "/api/trpc", req, router: appRouter, createContext: () => createTRPCContext({ req }), }); export { handler as GET, handler as POST };

createTRPCContext里会解析 NextAuth 的 session,把当前用户塞进 ctx。这是 tRPC 和认证打通的关键一步,没有这一步,protectedProcedure 拿不到ctx.user。

第三个是Prisma 客户端优化。在 Next.js 开发模式下,热更新会导致 PrismaClient 被反复实例化,最终的警告就是连接数爆掉。t3code 使用全局单例模式来规避这个问题:

import { PrismaClient } from "@prisma/client"; const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }; export const prisma = globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

第四个是Tailwind 的暗色模式配置。为了保证主题切换流畅,建议用 class 策略而不是系统偏好,需要在tailwind.config.ts里声明,并在根布局挂载一个主题切换脚本,避免刷新时闪烁。

3.3 一个完整需求的功能落地

理论说再多,不如跑一个真实需求。我在 t3code 里内置了一个"公告管理"模块作为参考样例,功能包括列表分页、发布公告、设为置顶、仅限管理员操作,涵盖了 CURD、权限、状态流转这几个典型场景。

列表页的 query 大概是这样的:

list: protectedProcedure .input( z.object({ page: z.number().default(1), pageSize: z.number().default(10), keyword: z.string().optional(), }) ) .query(async ({ ctx, input }) => { const where = { ...(input.keyword ? { title: { contains: input.keyword, mode: "insensitive" } } : {}), }; const [items, total] = await Promise.all([ prisma.announcement.findMany({ where, skip: (input.page - 1) * input.pageSize, take: input.pageSize, orderBy: { createdAt: "desc" }, }), prisma.announcement.count({ where }), ]); return { items, total }; }),

前端页面组件里,我直接用useQuery从 tRPC 取数:

const { data, refetch } = api.announcement.list.useQuery({ page: currentPage, pageSize: 10, keyword: searchKeyword, });

这模块完整跑通之后,后续新业务模块基本就是复制它改表名和字段的事。你会感受到这套模板真正省时间的地方:不是省在某个单一功能,而是省在"从页面到接口到数据库"这条路完全没有任何类型断层。

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

4.1 问题速查表

我整理了一组 t3code 系列项目里最高频的问题,按出现概率排序:

问题现象根本原因解决思路
tRPC 请求 401,接口明明公开忘记把 procedure 从publicProcedure换成非保护版,或前端客户端没有正确传 session检查 procedure 类型,以及trpc.ts里的session: null处理逻辑
Prisma 类型报错找不到ctx.prisma在createTRPCContext里没有挂 Prisma 客户端按全局单例导入并挂到 ctx,具体配置见上文
PostgreSQL 提示prepared statement already existsPrisma 与 pgBouncer 在事务模式下的已知冲突请用 session 模式连接,或在连接串里加?pgbouncer=true
NextAuth 回调地址 404NEXTAUTH_URL写错,或者 App Router 下[...nextauth]路由目录不对确认完整路由是app/api/auth/[...nextauth]/route.ts
Tailwind 修改 class 后页面不生效content 配置没有把新文件扩展名包含进去检查tailwind.config.ts的 content 数组是否覆盖./src/**/*.{ts,tsx}
useQuery在服务端组件里报错客户端 hook 被用在服务端组件把使用 hook 的区块拆到独立客户端子组件中
页面刷新后 tRPC 数据丢失没有配置persister或batching不正确检查QueryClient配置,curl 时注意请求体格式

这些都是我实测遇到的真实问题,不是从官方文档抄来的,很多问题在日志里给出的报错很抽象,按表里的"根本原因"去排查会快很多。

4.2 三个容易踩的深坑

坑一:NextAuth 的 typeof 问题导致类型报错。我把 provider 配置写在authOptions.ts里,因为数组类型在[...nextauth]/route.ts里推断不一致,会出现Type 'Provider' is not assignable to type...的错误。解决方案是给 config 加显式类型注解:

import { type NextAuthOptions } from "next-auth"; export const authOptions: NextAuthOptions = { providers: [GitHubProvider({ ... })], ... };

这个真的折磨了很久,最新版 NextAuth 的类型定义很严格,不加注解就是过不去。每次翻文档要找半天,现在模板里已经提前处理好了。

坑二:OAuth 登录时数据库的 email 字段冲突。GitHub 的 email 有可能是noreply结尾的隐私邮箱,用户的邮箱可能在注册后又修改。如果拿email当唯一键去 upsert 用户,第二次登录就会撞唯一约束,报错信息还很隐蔽。t3code 的写法是优先用providerAccountId匹配账户表,再反向关联用户,email 只作为辅助信息,不做唯一约束。

坑三:Prisma 的 migration 和 seed 脚本在 CI 中的顺序。如果你的流水线在构建镜像时执行prisma migrate deploy,而数据库还没建好,就会直接失败。我通常把 migration 放到 K8s Job 或容器的启动命令上,并加一个等重试机制,确保数据库先就绪。另外,seed 脚本不要在每次启动时自动跑,数据会重复插入导致主键冲突。

5. 性能优化与部署实战

5.1 构建体积与首屏性能

Next.js App Router 配合服务端组件后,首屏加载的 JS 体积能控制得非常小。以 t3code 里的 dashboard 页为例,生产构建后首屏 JS 大约 90KB gzip 以内,相比传统 CSR 项目动辄两三百 KB,提升非常明显。

但要注意,服务端组件也不是无脑把所有东西都放进去。如果某个组件本身有大量图表库依赖,把它放进布局里的客户端组件,依旧会拖慢全局加载。更好的做法是让图表组件只出现在用到它的页面,配合next/dynamic做懒加载。

const StatsChart = dynamic(() => import("./StatsChart"), { ssr: false, loading: () => <Skeleton />, });

图表、富文本编辑器这类重量级库,在 t3code 里全部采用动态加载策略。另外,所有图片资源我都配置了next/image的 remotePatterns,避免默认的图片优化器在部署时拦截外域图片。

我还给 t3code 加了最小化的数据缓存策略。在 App Router 的 server 端 fetch tRPC 数据时,会用 Next.js 内置的unstable_cache给低频变化的数据做 60 秒缓存,比如菜单配置、字典表。这样做能显著降低数据库压力,而且代码改动很小。需要注意的是,涉及用户敏感信息的数据绝对不能加全局缓存,权限上下文不同,缓存会被错误复用。

5.2 Docker 与 Nginx 部署要点

t3code 的部署我强烈推荐用 Docker,特别是团队里有人用 Windows 有人用 Mac 的时候,镜像可以完全抹平环境差异。Dockerfile 采用多阶段构建,依赖层利用缓存,产物体积控制在 250MB 以内(Node 20 slim 基础镜像)。

部署时最容易出问题的其实是 Nginx 代理。tRPC 的 HTTP 请求体和响应体可能很大,特别是列表接口返回大量数据时,如果 Nginx 默认的client_max_body_size没调大,前端会偶现 413 错误。这也是那种"本地好好的,上线就挂"的经典问题。

Nginx 配置里还要注意 WebSocket,Next.js 开发时的热更新和部分实时功能依赖它,生产环境如果需要,记得配置 Upgrade 头。如果单纯是 API 服务,不依赖 WebSocket,那么保持普通 HTTP/1.1 完全够用。

6. 我自己实测迭代后的几点体会

t3code 从第一版到现在,我最大的收获不是技术本身,而是意识到模板的价值在于"约定优于配置"。团队协作中,如果每个人对项目结构的理解不一致,代码风格很快就会失控。有了模板的硬约束,新同事也能写出风格一致的代码。

具体到技术体会上,T3 Stack 最适合的场景是 5 到 10 人的全栈团队做中小型产品。对于超大型分布式项目,它的 tRPC 单实例模型可能会在横向扩展时遇到瓶颈,那时你可能需要的是更微服务化的拆分。但如果你正在做一个内容类应用、内部工具、SaaS 前台加管理后台,这个组合确实顺手。

最后分享两个我自己一直在用的小技巧。第一个是在 t3code 里强制开启 TypeScript 的strict模式,并且把noUncheckedIndexedAccess也打开。开这两个选项的初期确实会多写不少类型守卫,但一旦养成习惯,之后几乎不会出现undefined is not a function这类夜里被报警电话叫醒的问题。第二个是在每个 tRPC mutation 里坚持写zod校验,不要因为输入来自内部系统就省略。内部接口同样会碰到脏数据,校验逻辑集中在 router 入口,远比散落在业务代码里好维护。

如果你也想在下一个项目里尝试 T3 Stack,建议直接从 t3code 的分支结构入手,看懂我拆分模块的思路,再往里填你的业务代码。欢迎在评论区聊聊你在实践过程中遇到的问题,特别是数据库设计和权限校验的部分,有很多细节值得展开讨论。

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

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

立即咨询