T3 Stack 全栈项目实战:基于 t3code 打造完整 Web 应用 如果你在搜索引擎里敲下t3code多半是在找一个能直接抄作业的全栈项目而不是想看Next.js 很强大这种正确的废话。我前阵子正好把一个基于 T3 Stack 的实战项目整理成了这套t3code包含用户注册登录、文章发布、评论互动、点赞关注、后台管理这些完整业务闭环。考虑到仓库名只有这么几个字我按自己团队里跑得最稳的一套 T3 Stack 实现来展开讲述代码细节会尽量给到位。t3code是一套使用 T3 Stack 构建的全栈应用示例代码。T3 Stack 这个名字可能有人听过它是目前 TypeScript 全栈生态里很流行的一套组合拳Next.js 负责页面和渲染TypeScript 负责类型检查tRPC 负责前后端接口调用Tailwind CSS 负责样式Prisma 负责数据库操作NextAuth.js 负责登录认证。这套技术选型并不是为了追新而是为了让开发者在写业务的时候少操一份心。t3code存在的意义就是帮你把这几样工具串成一个能跑、能开会、能上线的产品同时把过程里容易踩的坑提前标出来。这个项目尤其适合三类人。第一类是刚学完 React、正准备跨进全栈的新手跟着代码走一遍能少走很多弯路。第二类是技术负责人想拿一套内部培训用的参考代码t3code的目录结构和服务端划分可以作为团队规范的模板。第三类是独立开发者周末想验证一个产品想法用这套代码十分钟就能起一个带数据库和登录的 MVP。如果你属于其中任何一类下面这套拆解应该能给你省下不少时间。我现在不会按先讲 Next.js、再讲 Prisma、最后讲部署这种孤立的方式来说因为你看完还是会懵。我按真实的项目生长顺序来先讲整体设计为什么要这样拆再讲核心细节怎么落地然后亲手带着你把关键模块实现一遍最后把常见的坑和排查方法列出来。这样无论你是想直接拿来改还是想理解原理都能有收获。1. 内容整体设计与思路拆解1.1 把全栈应用的复杂度压到最低真正动手写过全栈项目的人都知道最折磨人的不是某个单独的技术难而是连起来的过程。前后端怎么约定接口字段校验要写几份后端改了一个字段类型前端会不会第二天才发现部署到线上之后数据库连接串怎么管理这些问题如果只有一个框架它们是分散的但当你用一整条链路去解决体验会完全不同。t3code的设计初衷就是把这类连接成本压到最低。我选了 T3 Stack 作为整个代码库的底座打通了六个环节Next.js 处理页面与服务端渲染TypeScript 保证类型安全tRPC 负责前后端调用Tailwind 快速出样式Prisma 管理数据库NextAuth 处理登录。这个组合不是随便拼的它们有一个共同点都把开发体验放在优先级很高的位置。尤其是 tRPC后端定义一个查询前端立刻拿到完整类型提示接口文档在这一套体系里几乎可以消失。在动手写之前我先反复问了自己一个问题t3code到底要做一个示例还是一个能作为产品起点、长期维护的模板最终我选择了后者。因为如果只是一个 Demo我会怎么写开心怎么来但那对你没有参考价值如果把它当成产品起点每一条代码都要考虑三个月后还能不能看懂。事实证明这个选择几乎影响了后面的所有细节。另外一个关键选择是约定优于配置。t3code没有发明新架构而是老老实实遵循 create-t3-app 的目录约定src/server放所有服务端逻辑src/app放页面src/trpc放 tRPC 的初始化代码。这样任何一个熟悉 T3 Stack 的人接手都能第一时间找到对应文件。项目代码的可读性有时候比某个巧妙的设计重要得多尤其是当你不打算一个人维护的时候。1.2 看一遍六层依赖与一次完整请求t3code里的层次关系很清晰从底到上可以画成一张表。不需要背写代码的时候我会顺手对照层次位置负责的事数据库PostgreSQL / SQLite持久化存储全部业务数据Prisma 数据层src/server/db.ts提供唯一数据库客户端实例tRPC 服务端src/server/api/root.ts与 routers定义全部业务 APItRPC 客户端src/trpc/react.ts前端调用 API 的统一入口Next.js 页面src/app服务端渲染与客户端交互样式层Tailwind CSS设计系统与原子类以t3code里最核心的文章发布功能为例一次普通的读请求长这样浏览器请求 Next.js 页面Next.js 在服务端调用 tRPC 的post.list过程tRPC 通过 Prisma 查询数据库拿到数据后返回给 Next.js由服务器组件渲染成 HTML。整个过程只有一个 API 层不存在 REST 路由、redux、axios 这些中间人。这个设计的精妙之处在于t3code把数据获取的入口统一收敛到了 tRPC。前端组件里可以直接写const posts await api.post.list.query()然后放心使用返回结果的类型。如果数据库表结构改了Prisma 会重新生成类型TypeScript 立刻高亮所有受影响调用点一个错误的修改根本走不到运行环节。关于类型安全链路我用生活化的方式解释一下以前你前后端两个人各写一份接口文档后端改了字段你不一定知道上线当天才发现参数名对不上就像两个厨子各按自己的菜谱做同一道菜出锅了才发现口味不一致。现在t3code的 TypeScript 类型从数据库一路穿透到页面组件前后端手里拿的是同一份菜谱改菜谱的地方必然连带改动所有依赖这道菜的地方想不一致都难。2. 核心细节解析与实操要点2.1 目录结构里的设计语言先看一眼t3code的核心目录结构t3code/ ├── prisma/ │ └── schema.prisma ├── src/ │ ├── app/ │ │ ├── (auth)/ │ │ ├── dashboard/ │ │ ├── layout.tsx │ │ ├── page.tsx │ ├── server/ │ │ ├── api/ │ │ │ ├── root.ts │ │ │ └── routers/ │ │ ├── auth.ts │ │ └── db.ts │ ├── trpc/ │ │ ├── react.ts │ │ ├── server.ts │ │ └── init.ts │ ├── components/ │ └── styles/ └── .env.exampleprisma/目录里只有schema.prisma所有数据模型定义都在这一个文件里完成它是数据库结构的唯一事实来源。src/server/里又细分了api/、auth.ts、db.ts分别放 tRPC 路由、NextAuth 配置和 Prisma 客户端实例。为什么要单独拎一个db.ts因为 Prisma Client 应当作为单例存在。如果每个路由文件都new PrismaClient()开发模式下热更新会导致连接数暴涨终端里全是绿色的Too many connections警告这是非常典型的血泪教训。src/trpc/是整个代码库里最重要的一个目录。里面不仅有initTRPC、createTRPCContext还定义了publicProcedure、protectedProcedure两个过程工厂。前者允许匿名访问后者会先校验 session、再执行后续逻辑。t3code在做创建文章、删除评论这类写操作时全部走 protectedProcedure这样业务代码不用到处写权限判断。前端部分没有把组件按页面硬切而是按功能区块拆。auth/放登录注册相关组件posts/放文章列表和编辑器。这一点我吃过亏以前把所有组件堆进一个_components文件夹项目一改版根本分不清组件属于哪条业务线。后来改成按 feature 组织每次需求变更只需要打开对应目录改代码范围一目了然。2.2 为什么是 tRPC而不是 REST 或 GraphQL这是t3code在技术选型时争议最大的点。很多朋友一上来就问我你为什么不老老实实写 REST 接口我也不是没犹豫过。REST 直观GraphQL 灵活但 tRPC 的核心优势是把 API 调用变成一次类型安全的函数调用。在 Next.js 服务端组件里你不需要 fetch URL、不需要解析 JSON、不需要记忆接口路径直接调用导入进来的函数就行。写业务代码时你根本不需要关心这个 API 的 URL 是什么而只关心我要拿到什么数据开发速度完全是另一回事。从网络层面讲tRPC 只是把 JSON 数据包通过 HTTP POST 发到/api/trpc本质上没有引入任何黑魔法。它和 REST 的差别在约定层面REST 强调资源语义和 HTTP 动词tRPC 强调调用过程和过程名。对于内部全栈项目来说REST 的语义设计成本是不必要的开销GraphQL 又复杂到需要团队统一规范才能玩得转。这不是说它们不好如果你要开放 API 给第三方我仍然建议用 REST 或 GraphQL但t3code的定位是快速构建内部产品tRPC 刚刚好。简单对比一下三者维度RESTGraphQLtRPC类型安全需要手动维护类型和 schema通过 Codegen 间接获得原生类型推断学习成本低高schema 设计、缓存方案复杂中低适合场景开放 API、第三方集成复杂聚合查询、多客户端内部全栈产品接口文档需要 Swagger 等需要维护 schema类型自带调试手段浏览器直接访问Playground浏览器 Network 面板如果你想体验这套类型穿透的爽感可以做一个高频操作在src/server/api/routers下新建一个 router注册到根路由后刷新一下页面前端输入框里立刻能拿到新 router 下的所有 query。这个体验在新手阶段的冲击力很大我第一次用的时候觉得不现实以为是编辑器插件开的什么神秘通道后来才明白这就是 TypeScript 推断的威力。2.3 几个必须提前约定好的细节环境变量集中管理。t3code把DATABASE_URL、NEXTAUTH_SECRET、NEXTAUTH_URL都放在.env里并用 Zod 在启动时校验。漏配一个变量项目会在启动阶段直接报错而不是运行后静默失败。这个体验对部署尤其重要早一分钟失败就少一小时排查线上诡异问题。前端组件不能直接 importserver/下的模块。Next.js 会报错因为这会把 Node 服务端代码打进浏览器包。t3code在服务端入口文件顶部用了server-only包误引用时编译阶段就会失败。TypeScript 严格模式不要关。strict和 ESLint 规则都开着代价是初期写代码时频繁被挑战收益是之后每次改动代码都有安全感。喊严格模式没用的人通常都是没在大型重构里受益过。这些细节看起来琐碎却决定了你能否在一个月后、三个月后继续维护这套代码。t3code里所有目录依赖方向都是朝内的页面依赖 routerrouter 依赖服务端工具服务端工具依赖数据库。任何依赖关系反向的结构都会在 code review 里被当场打回。3. 实操过程与核心环节实现3.1 初始化项目与基础配置上手t3code最快的路径是从 create-t3-app 生成。我在本地执行npx create-t3-applatest t3code交互式提示一路问下来要不要 TypeScript要不要 App Router要不要 tRPC要不要 Prisma要不要 Tailwind要不要 NextAuth这个脚手架的好处是它已经替你把各框架的版本冲突、初始化代码、目录结构都理顺了自己从零拼装没准要多花三个小时还不一定保证能跑通。我建议除非你对 ESLint 有强烈偏好否则其他项全选 yes 就好。生成后复制环境变量文件cp .env.example .env然后根据你的本地数据库情况修改DATABASE_URL。本地开发最省事的方式是先用 SQLite把 prisma 的 provider 设为sqlite连接串写成file:./db.sqlite。SQLite 不需要额外安装任何服务跑完迁移就能预览效果等部署到线上再切换到 PostgreSQL 也不麻烦改一行 provider、改一个连接串就行。很多团队就是在本地 SQLite 上把功能全部验证完再在 CI 里跑 PostgreSQL 测试效果很好。接着执行npx prisma migrate dev --name init npm run dev浏览器打开http://localhost:3000能看到首页有完整的样式和示例数据说明项目基础已经跑通。如果这一步有任何报错大概率出在.env文件没配好可以直接跳到第 4 章的问题速查表去对号入座。3.2 数据模型设计的完整示例t3code的核心业务模型主要有用户、文章、评论、点赞、关注关系。用 Prisma 写数据模型的体验非常直观先看用户和文章的模型model User { id String id default(cuid()) name String? email String? unique emailVerified DateTime? image String? accounts Account[] sessions Session[] posts Post[] comments Comment[] likes Like[] } model Post { id String id default(cuid()) title String content String published Boolean default(false) createdAt DateTime default(now()) updatedAt DateTime updatedAt authorId String author User relation(fields: [authorId], references: [id]) comments Comment[] likes Like[] } model Comment { id String id default(cuid()) body String createdAt DateTime default(now()) authorId String postId String author User relation(fields: [authorId], references: [id]) post Post relation(fields: [postId], references: [id]) }这里 id 统一用cuid()而不是自增整数主要考虑是分布式场景不容易撞 id且公开接口不希望暴露业务量。实际项目里如果一开始用了自增 ID之后想改成 cuid会牵动大量外键所以干脆在一开始就定好。多对多关系是新手比较容易写懵的部分。比如用户和文章之间如果有点赞关系需要显式定义一个中间模型model Like { id String id default(cuid()) userId String postId String user User relation(fields: [userId], references: [id]) post Post relation(fields: [postId], references: [id]) createdAt DateTime default(now()) unique([userId, postId]) }这个设计把一个用户只能给同一篇文章点一次赞写进了数据库层面比单纯在业务代码判断更稳。为什么不在 User 和 Post 之间用隐式多对多关系我建议显式建中间模型因为之后大概率要加 createdAt、状态位这类字段隐式表到时候改起来会麻烦得多。修改完模型后执行npx prisma migrate dev --name add_comment_and_like npx prisma generate这里必须强调prisma migrate dev只适合开发环境它会把本地迁移文件和环境同步线上数据库的更新应该用prisma migrate deploy。如果把它们混用了最直接的后果是部署流程里出现一堆本地无关的迁移记录甚至不小心把开发环境的脏数据同步到生产库。3.3 tRPC 路由与完整鉴权实现t3code在src/server/api/root.ts里把各个子路由合并成 appRouter然后被 Next.js 的/api/trpc路由接管。以文章创建为例核心代码import { z } from zod; import { createTRPCRouter, protectedProcedure } from ~/server/api/trpc; import { db } from ~/server/db; export const postRouter createTRPCRouter({ create: protectedProcedure .input( z.object({ title: z.string().min(1).max(100), content: z.string().min(1), published: z.boolean().default(true), }) ) .mutation(async ({ ctx, input }) { return ctx.db.post.create({ data: { ...input, authorId: ctx.session.user.id, }, }); }), });这段代码已经完整表达了三层业务约束只有登录用户能创建文章标题必须有内容且不超过 100 字正文非空当前用户 ID 来自服务端 session而不是前端传参。最后这一点极其重要如果依赖前端传用户 ID任何有点经验的人都能伪造请求越权操作。直接在服务端从 ctx 里拿 session 里的 ID是t3code从安全层面必须保证的。列表查询同样简单list: publicProcedure .input(z.object({ onlyPublished: z.boolean().default(true) })) .query(async ({ ctx, input }) { return ctx.db.post.findMany({ where: input.onlyPublished ? { published: true } : undefined, orderBy: { createdAt: desc }, include: { author: { select: { name: true, image: true } } }, }); }),前端调用时use client; import { api } from ~/trpc/react; import { useRouter } from next/navigation; export function PostCreateForm() { const router useRouter(); const createPost api.post.create.useMutation({ onSuccess: () router.refresh(), }); 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 nametitle classNameborder p-2 placeholder标题 / textarea namecontent classNameborder p-2 placeholder正文内容 / button typesubmit disabled{createPost.isPending} {createPost.isPending ? 发布中... : 发布文章} /button /form ); }这段代码里的isPending直接控制按钮 loading提交成功后调用router.refresh()刷新服务端组件数据。整个过程没有手动发过一次 fetch、没有写过一行接口文档前后端类型保持一致。这就是t3code的核心体验。如果你做的是纯客户端交互场景useMutation返回的onSuccess里也可以做同样的数据刷新保证页面数据始终以数据库为准。3.4 登录注册与 Session 处理登录是任何全栈项目都绕不开的模块。t3code接入的是 NextAuth.js最省事的配置是 GitHub Provider。本地开发时在 GitHub OAuth Apps 里申请一个 client id 和 secret填入.env就能登录。如果不想用第三方也可以用 Credentials Provider自己写用户名密码校验逻辑但这样你得自己处理密码哈希、找回密码等流程工作量并不小。我建议第一版先用 GitHub 登录验证业务后续再考虑加更多 Provider。配置的核心在src/server/auth.tsexport const authOptions: NextAuthOptions { session: { strategy: jwt }, providers: [GitHubProvider({ clientId: env.GITHUB_CLIENT_ID, clientSecret: env.GITHUB_CLIENT_SECRET })], callbacks: { jwt({ token, user }) { if (user) token.user user; return token; }, session({ session, token }) { return { ...session, user: token.user }; }, }, };为什么 session 策略用 JWT 而不是 database对大多数t3code这类轻量产品来说JWT 更简单、不额外依赖数据库 session 表且 Vercel 这类 Serverless 环境下更容易扩展。如果你的产品要踢人下线、服务端 session 吊销这类强管理再切回 database session 模式也不难。session 与 tRPC 的配合是关键。createTRPCContext里调用getServerSession(authOptions)这样每个 tRPC 请求都会携带已登录用户信息。之后在protectedProcedure里直接解构ctx.session就能拿到用户 ID。这套方案比我在一些项目里见过的自己写 JWT 中间件干净得多session 刷新、过期都由 NextAuth 管理业务代码里只需要关心当前用户是谁。前端判断是否登录我通常用一个useSessionhook 包一下。但这里要强调一个容易写错的点useSession是给客户端组件用的 hooks服务端组件要判断登录状态应该用auth()方法两者不能混用。t3code在src/server/auth.ts里导出了服务端用的auth()也在src/trpc/init.ts里导出了客户端用的 session 相关类型。如果你在服务端组件里错误使用useSessionNext.js 会直接报错反过来在客户端组件里 import 服务端模块也会被server-only拦下来。这些约束看着严其实是对你负责。4. 常见问题与排查技巧实录4.1 问题速查表把t3code从开发到部署过程中我遇到过的、以及团队新同学经常踩的问题整理成下面这张表现象主要原因排查与解决启动时报 Missing env variables.env 没复制或字段不全检查 DATABASE_URL、NEXTAUTH_SECRET、NEXTAUTH_URL 是否齐全页面报 Server-only module imported by client component客户端组件引用了 server 目录模块把逻辑挪到服务端组件或通过 tRPC 调用代替直接 importtRPC 类型不更新router 修改后前端类型未重建重启 dev server必要时rm -rf .next修改 Prisma schema 后报找不到模型没执行 prisma generate每次 migrate 后执行npx prisma generateNextAuth 登录后 session 为空Provider 回调或 session strategy 配置不对确认session.strategy jwt并检查 callbacks 返回值页面能打开但数据是空的数据库没跑迁移或 Seed 没执行执行prisma migrate dev和prisma db seed部署到 Vercel 后功能挂用了本地 SQLite 路径或漏环境变量切 PostgreSQL重新配置线上环境变量某次部署后数据库表对不上migrate dev 用在生产环境生产环境改用npx prisma migrate deploy这张表的前三条几乎每个新接手t3code的人都会遇到尤其第一条通常不是配置有多难而是忘了cp .env.example .env。把.env放进.gitignore已经是标配所以在没有复制之前项目启动就是必炸的。4.2 三个值得单独展开的细节第一个是.next目录缓存问题。tRPC 类型理论上不需要额外生成但 dev server 对文件修改的监听有时会漏掉深层类型变化导致前端拿到的还是旧类型。我自己处理这类问题有一套标准动作先停止 dev server再执行rm -rf .next最后重新npm run dev。很多时候重启大法没用是因为没有清缓存问题出在旧的编译产物上。第二个是 Seed 数据问题。第一次跑迁移后如果表里没有数据列表页面会大面积白屏容易被误认为代码 bug。我的做法是在prisma/seed.ts里创建两三个固定测试用户和一二十篇文章然后执行npx prisma db seed这样登录验证、分页测试、关注点赞这些操作都有真实数据支撑开发效率高很多。第三个是部署环境下的连接串问题。很多第一次部署的人把DATABASE_URL写成file:./db.sqlite然后丢到 Vercel 上线上自然找不到文件。t3code的部署建议是本地用 SQLite 验证完功能后生产环境切换 PostgreSQL.env.production里单独维护连接串部署前先本地执行一次npm run build让 TypeScript 和 ESLint 在 CI 前把问题暴露出来。说到后台上线还有一个很多人忽略的点Next.js 在构建时如果页面里有动态数据千万别用静态导出output: export。t3code大量依赖服务端组件调数据库必须让 Node.js 服务运行在 Vercel 或自己的服务器上。如果你在搭建时误开了静态导出会出现部署成功但页面全是 404的怪现象。5. 从本地到线上部署与后续扩展方向5.1 一键部署的完整路径部署t3code到生产环境的最佳选择是 Vercel因为 Next.js 和 Vercel 同生态基本不用改代码。第一步先把数据库切到 PostgreSQL。如果不想自己维护数据库Supabase 或 Neon 都提供免费层级的 Postgres连接串复制到.env.production即可。第二步把DATABASE_URL、NEXTAUTH_SECRET、NEXTAUTH_URL、GitHub OAuth 的 client id 和 secret 全部配置到 Vercel 的环境变量面板。第三步设置构建命令为项目默认的迁移加构建流程或者直接在 Vercel 的 Build Command 里写npx prisma migrate deploy npx prisma generate npm run build注意NEXTAUTH_URL在线上一定要是真实域名也就是https://yourapp.vercel.app而不是http://localhost:3000。这个写错会导致 OAuth 回调失败用户点击登录后跳转到奇怪地址排查起来很费劲。如果你自己有服务器用 Docker 部署也一样把 Dockerfile 写成多阶段构建用next start作为启动命令数据库连接串同样来自环境变量。部署完成后建议立刻跑一遍核心链路注册登录、发文章、评论、点赞。别只在首页看一眼就以为万事大吉我吃过这个亏首页静态渲染正常但登录后接口全部超时最终发现是部署环境没把数据库迁移跑完。把核心链路本地验证一遍能省掉一半线上为啥比本地慢/挂的疑问。5.2 后续可以往哪个方向扩展t3code的架构给后续扩展留了很自然的接口。想加文件上传可以在 tRPC 里增加一个上传 mutation把文件写入对象存储然后保存 URL想加全文搜索直接用 PostgreSQL 的全文检索或外接一个搜索服务想加通知系统可以在 Prisma 里建一个 Notification 表服务端在评论、点赞创建后写一条记录。如果你想让t3code成为一个博客系统还需要补上这几块文章的 Markdown 渲染、SEO 元数据、站内搜索。如果你想把它做成社区产品那要考虑审核机制和内容举报这些都可以在现有 tRPC 路由上继续扩展不会推翻整个架构。我自己下一步的计划是给t3code加上一套基于角色的权限系统普通用户、管理员、超级管理员。因为目前的 protectedProcedure 只区分是否登录而真实产品往往还要区分是否有权限操作。具体实现不复杂可以在 protectedProcedure 之外再包一层 adminProcedure在调用链路上多校验一次用户角色字段。这条加完之后后台管理页面就能顺理成章地放开写整个示例的完整度也会再上一个台阶。6. 最后的实际操作体会我在实际整理t3code的过程中最大的体会是这种全栈套件型项目最值钱的地方不是某段代码写得有多优雅而是它把一堆优秀的工具用一种克制的方式组合在一起。你不需要理解每个框架的全部源码只要顺着流程跑一遍就能切实感受到端到端类型安全带来的踏实感。全栈项目最大的成本永远在维护而维护成本的高低往往在你写下第一行目录结构时就已经决定了。最后再分享一个小技巧如果你打算拿t3code当模板做自己的产品第一件事不是打开编辑器改代码而是把prisma/schema.prisma从头到尾读三遍。因为数据库模型就是业务边界边界画错了后面每一个页面都要跟着返工。先把模型设计得尽量贴合真实需求再顺着 tRPC 的链路一层层往上补页面比直接在组件里堆逻辑要高效得多。祝你把t3code改成你自己的样子然后跑起来、部署出去、收到第一个真实用户。