很多人第一次看到“t3code”这个名字大概率会懵一下这到底是个库、一个工具还是某个新框架如果你是搞全栈开发的老手应该会立刻联想到社区里那套非常有名的 T3 Stack——由 Next.js、tRPC、Tailwind CSS 和 Prisma 组成的全栈 TypeScript 组合拳。t3code 本质上就是围绕这套技术栈沉淀出来的实战代码项目把“从 0 到 1 搭一个生产级全栈应用”这件事变成一套可复制的标准化流程。这篇文章我会以一次完整的实战经历为主线讲清楚 t3code 里每一个核心技术点是怎么选、怎么配、怎么用的以及我在实际搭建过程中踩过的坑。不管你是刚想进入全栈 TypeScript 生态的新手还是已经在写 Next.js 但想引入 tRPC 和 Prisma 的开发者跟着走一遍至少能少翻三天文档。1. t3code 到底解决什么问题1.1 为什么全栈项目越写越乱先说说我为什么要搞 t3code 这个项目。这几年接到的中小型全栈应用特别多比如内部数据看板、小型电商后台、内容管理工具。这些项目有个共同点业务逻辑说复杂也不算复杂但就是脏活累活不少而且技术选型一旦没定好后期维护成本会直线上升。以前我习惯用前后端分离的玩法Next.js 只做前端后端单独开一个 Express 或 NestJS 服务中间再扯出 REST API。这套方案对于团队大、边界清晰的产品没问题但放到一个人或两三个人维护的中型项目里就显得很笨重。你得同时维护两个项目仓库、两套部署流程、两套类型定义前端拿到接口文档还要手动去对齐类型。哪天后端更新了字段前端编译不报错运行时突然踩到 undefined这种问题排查起来真的非常心累。t3code 的核心思路就是把一个全栈应用收拢到一个项目里用同一个语言TypeScript把前后端的类型串联起来让“改数据模型”这件事直接从源头传到前端页面而不是靠文档和自觉去同步。1.2 T3 组合拳的核心逻辑T3 Stack 这个名字里的 T3最早是指三个 T 开头的核心工具Tailwind CSS、tRPC、TypeScript。后来大家习惯把 Next.js 和 Prisma 也算进去因为实际项目中它们几乎是绑定的。老实说这个组合并不是什么黑科技就是把已经被市场验证过的几个工具用一套约定好的方式组合起来。每个工具都有自己的位置Next.js 负责应用框架和运行时既能跑页面又能开 API 路由Prisma 负责数据库访问层把 SQL 表映射成 TypeScript 类型tRPC 负责 API 调用让你像调用本地函数一样调用后端方法Tailwind CSS 负责样式所有样式类直接在 JSX 里写不用来回切文件。我最看重的其实是“类型安全”这件事。tRPC 和 Prisma 之间有一条天然的类型链路数据库表结构变了 → Prisma Client 类型跟着变 → tRPC Router 的参数和返回值类型跟着变 → 前端页面里的调用也跟着变。任何一环不匹配编译时就会报错基本不会把问题拖到线上。这种体验在传统 REST 方案里是享受不到的。2. 从零初始化 t3code 项目2.1 环境准备与脚手架命令如果你也想复现 t3code环境其实不复杂。Node.js 版本建议至少 18最好用 20 以上的 LTS 版本。包管理器我建议直接上 pnpm一方面是安装速度快另一方面是 t3code 这种多依赖项目对硬链接支持更好后期不会有 node_modules 里文件重复导致的各种诡异问题。初始化命令是官方脚手架 create-t3-apppnpm create t3-applatest t3code执行以后它会问一长串问题比如项目名、是否使用 NextAuth、是否使用 Prisma、是否使用 Tailwind、是否使用 tRPC、是否启用 ESLint 和 Prettier。这里我建议第一次跑的人全选 Yes先把完整的全家桶结构拉出来再逐个删减。不要在初始阶段盲目做减法因为你还不知道哪些工具会在后期帮上大忙。初始化完成以后进入目录、起服务cd t3code pnpm install pnpm dev这时候浏览器打开 http://localhost:3000能看到一个默认的欢迎页说明骨架已经通了。但我得说真正的活儿才刚刚开始因为脚手架只是给了你一个“空壳”业务代码、数据模型、路由设计都需要自己逐个补上。2.2 初始化配置逐项说明很多人把脚手架生成的代码当作“能跑就行”直接开始写业务。这是最大的误区。t3code 这类项目里配置文件决定了后续开发的舒适度值得在开头花半小时逐项看一遍。首先是next.config.mjs里面会有一段类似下面这样的配置/** type {import(next).NextConfig} */ const config { reactStrictMode: true, transpilePackages: [geist], }; export default config;reactStrictMode必须留着开发阶段它会帮你发现潜在的数据更新顺序问题。transpilePackages是给 Geist 字体用的不用动。然后是tsconfig.json里面有非常关键的一个路径别名配置{ compilerOptions: { baseUrl: ., paths: { ~/*: [./src/*] } } }也就是说你后续所有业务代码的引入路径都会以~/开头而不是一长串../../。比如引入 Header 组件直接写import Header from ~/components/Header就行。这个小细节能让代码整洁度提升一个档次尤其是路由嵌套深的时候不用数着../过日子。另外还有一个容易忽略的文件src/server/db.ts。脚手架会生成一个 Prisma Client 的单例import { PrismaClient } from prisma/client; const globalForPrisma globalThis as unknown as { prisma: PrismaClient | undefined }; export const db globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV ! production) globalForPrisma.prisma db;这个单例模式解释了为什么项目在开发模式下不会因为热更新把数据库连接池打爆。如果你在新项目里自己写new PrismaClient()放到模块顶层过不了多久就会遇到数据库连接数超限的问题。因为 Next.js 开发服务器的每次热编译都会重新执行模块导致反复新建连接实例。t3code 这个写法直接把实例挂到了全局对象上热更新时复用旧实例非常实用。3. 核心环节数据模型与数据库接入3.1 用 Prisma 设计真实业务表t3code 里我这次做一个轻量的待办协作工具。业务模型不复杂但能覆盖大多数项目的典型关系用户User、任务清单Board、任务卡片Card、成员关系Member。prisma/schema.prisma里我是这么写的generator client { provider prisma-client-js } datasource db { provider postgresql url env(DATABASE_URL) } model User { id String id default(cuid()) name String? email String unique boards Board[] relation(BoardMembers) cards Card[] } model Board { id String id default(cuid()) title String createdAt DateTime default(now()) owner User relation(fields: [ownerId], references: [id]) ownerId String members User[] relation(BoardMembers) cards Card[] } model Card { id String id default(cuid()) title String status String default(todo) board Board relation(fields: [boardId], references: [id]) boardId String assignee User? relation(fields: [assigneeId], references: [id]) assigneeId String? order Int default(0) } model Member { boardId String userId String role String default(editor) assignedAt DateTime default(now()) id([boardId, userId]) }写这个 schema 时有几个点值得展开。主键我没有用自增数字而是用的cuid()。自增 ID 在并发接口场景下容易被遍历猜测暴露业务量而且后续做数据迁移和合并时容易碰撞。CUID 是分布式友好的字符串 ID生成在客户端/服务端都很快做外键关联也更安全。Member这个表用复合主键id([boardId, userId])意思是同一个成员在同一个看板里只能出现一次。这在数据库层面就把重复关系挡住了不用在业务代码里再手动校验。3.2 迁移与种子数据实战schema 写完之后第一件事不是直接改代码而是先建表pnpm prisma migrate dev --name init这一步会根据 schema 自动生成数据库表并且会生成 Prisma Client。migrate 这个名字起得很贴切——每次 schema 变更都对应一次数据库结构的演进记录团队协作时其他人只需要跑pnpm prisma migrate dev就能把本地表结构同步到最新状态不用互相传 SQL 文件。我建议顺手做一份种子数据。t3code 项目里prisma/seed.ts可以这样写import { PrismaClient } from prisma/client; const prisma new PrismaClient(); async function main() { const alice await prisma.user.upsert({ where: { email: aliceexample.com }, update: {}, create: { email: aliceexample.com, name: Alice }, }); const board await prisma.board.create({ data: { title: 产品发布计划, ownerId: alice.id, cards: { create: [ { title: 完成首页视觉改版, status: todo, order: 1 }, { title: 接支付回调, status: doing, order: 2 }, { title: 整理全套组件文档, status: done, order: 3 }, ], }, }, }); console.log(种子数据创建完成, { boardId: board.id }); } main() .catch((e) { console.error(e); process.exit(1); }) .finally(async () { await prisma.$disconnect(); });跑种子数据之前记得在package.json里加上prisma: { seed: tsx prisma/seed.ts }然后执行pnpm prisma db seed这里有一个很容易踩的坑如果直接用ts-node跑 seed 文件大概率会报模块解析错误因为 t3code 的路径别名是~/开头的而 seed 脚本运行在 Prisma 的 Node 环境里。我建议直接用tsx它对 TypeScript 的支持更省心不用单独配 ts-node。4. API 层tRPC 的高效玩法4.1 tRPC 与传统 REST 的对比如果说 Prisma 解决了数据库层的类型安全那 tRPC 解决的就是 API 层的类型安全。传统 REST 接口开发流程是这样的后端写接口 → 用 Postman 调试 → 写接口文档 → 前端根据文档手写类型定义 → 前端调用。只要接口一多这套流程里最容易丢的就是“前端类型与后端实际返回不一致”。tRPC 的思路完全不同。后端定义好一个函数前端直接把这个函数import 过来“调用”参数和返回值的类型自动跟着走。这背后其实是远程过程调用RPC的老概念只是 tRPC 在 TypeScript 环境下把这个体验打磨得很顺。拿我这个待办项目为例后端要提供一个“获取看板详情”接口// src/server/api/routers/board.ts import { z } from zod; import { createTRPCRouter, publicProcedure } from ~/server/api/trpc; export const boardRouter createTRPCRouter({ getDetail: publicProcedure .input(z.object({ boardId: z.string() })) .query(async ({ ctx, input }) { return ctx.db.board.findUnique({ where: { id: input.boardId }, include: { cards: { orderBy: { order: asc }, }, members: true, }, }); }), });前端页面里调用const { data: board } api.board.getDetail.useQuery({ boardId: xxx });注意上面两个文件里出现的boardId和返回结构是完全共用的同一个类型。如果数据库模型里把Board.title改成Board.name你甚至不用手动改业务代码只要重新生成 Prisma Client编译时这里就会报错。这就是整体类型链路的价值改一处编译器帮你扫完所有受影响的页面。4.2 跑通第一个服务端查询实际开发时我不建议一上来就写一大堆 Router 方法。先服务好一个最小场景把链路跑通再往里面加东西会顺手很多。我建议的落地顺序是在src/server/api/root.ts里注册好自己的 router先在页面上用.useQuery拉一次假数据确认 DevTools 的 Network 面板能看到/api/trpc/*请求再逐步往 router 里加 mutation写操作。接上一步的getDetail你可以在页面里这样展示import { api } from ~/utils/api; export default function BoardDetailPage({ params }: { params: { boardId: string } }) { const { data, isLoading, isError } api.board.getDetail.useQuery({ boardId: params.boardId, }); if (isLoading) return div正在加载看板.../div; if (isError) return div加载失败请检查网络或权限/div; return ( div h1 classNametext-2xl font-bold{data?.title}/h1 ul {data?.cards.map((card) ( li key{card.id} {card.title} - {card.status} /li ))} /ul /div ); }这一步跑通之后你会对 tRPC 的“函数式调用”体验有一种已经在写全栈应用的实感。而且它自动处理了 loading、error、缓存和失效重取。React Query 那一整套状态管理逻辑被包在了 tRPC 客户端里不需要自己维护请求状态这对中小项目来说太省心了。5. 认证与权限让应用真正可用5.1 NextAuth.js 集成要点t3code 的脚手架里默认集成了 NextAuth.js它跟 Next.js 的 API 路由结合得很好。但我第一次配的时候还是被几个细节卡了很久这里把关键点拆开讲。首先是环境变量。认证和数据库连接相关的一共需要三个DATABASE_URLpostgresql://user:passwordlocalhost:5432/t3code NEXTAUTH_SECRET生成一个至少32位的随机字符串 NEXTAUTH_URLhttp://localhost:3000NEXTAUTH_SECRET可以用 OpenSSL 生成openssl rand -base64 32如果你跳过了这个变量直接启动NextAuth 会在终端里弹出一个红色警告生产环境更是无法正常签发会话。这个警告在本地还好一旦部署到服务器上忘配了登录功能会直接变成摆设用户咋点都没反应。然后是认证配置。t3code 默认把配置放在src/server/auth.ts下面这段代码是整个配置的核心import { getServerSession } from next-auth; import { PrismaAdapter } from auth/prisma-adapter; import { db } from ~/server/db; import NextAuth from next-auth; import GitHub from next-auth/providers/github; export const { handlers, auth, signIn, signOut } NextAuth({ adapter: PrismaAdapter(db), providers: [ GitHub({ clientId: process.env.GITHUB_CLIENT_ID!, clientSecret: process.env.GITHUB_CLIENT_SECRET!, }), ], callbacks: { session({ session, user }) { if (session.user) { session.user.id user.id; } return session; }, }, });这里有个非常关键的设计session.user.id是从数据库用户记录里取出来的不是从 Session 里猜出来的。这样你在后续查询时可以直接用ctx.session.user.id来过滤数据权限比如“只看我参与的工作区”。5.2 权限控制落地方案有了认证就到了权限控制环节。t3code 里推荐的做法是在 tRPC procedure 上做统一拦截而不是每个页面里各自判断。比如我的看板可能只允许创建者删除成员只能编辑卡片。这时候就需要一个“只允许登录用户”的受保护 procedure以及一个“只允许看板创建者”的更高权限 procedure。import { TRPCError } from trpc/server; export const protectedProcedure createTRPCProcedure() .use(({ ctx, next }) { if (!ctx.session?.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { session: ctx.session }, }); });然后在具体 router 里deleteBoard: protectedProcedure .input(z.object({ boardId: z.string() })) .mutation(async ({ ctx }) { const board await ctx.db.board.findUnique({ where: { id: boardId }, }); if (board?.ownerId ! ctx.session.user.id) { throw new TRPCError({ code: FORBIDDEN }); } return ctx.db.board.delete({ where: { id: boardId } }); });权限校验放在服务端完成是这整套架构的核心安全边界。有人可能觉得在页面上把“删除按钮”隐藏掉就够了但那只是体验优化不是安全措施。真正可靠的做法是把校验放在数据库访问之前前端怎么操作都绕不过这一层。6. 部署上线与外网访问6.1 构建优化与环境变量本地开发跑得飞起不等于部署后一样稳定。t3code 的构建过程有两个容易出问题的点。第一个是环境变量缺失。prisma generate和next build在构建阶段都可能会读取DATABASE_URL如果你用的是在线的 PostgreSQL 服务比如 Neon、Supabase一定要在部署平台的 Environment 里把变量提前配好再触发构建。否则构建过程中 Prisma Client 会因为找不着数据库连接串而崩溃。第二个是数据库迁移顺序。我第一次部署时踩了个大坑本地数据库里有表但线上的 PostgreSQL 是空库直接pnpm start之后所有查询报错“关系不存在”。解决方案是在部署平台的构建命令里加一段pnpm prisma migrate deploy pnpm buildprisma migrate deploy不会像migrate dev那样生成新的迁移文件它只负责把已有的迁移记录按顺序应用到一个空数据库上非常适合生产环境初始化。6.2 平台部署难点记录我这次部署选的是 Vercel 加 Neon PostgreSQL 的组合整套流程比较顺但也有几个值得说的细节。首先是 tRPC 的请求路径。Vercel 上的 Next.js 默认走 serverless 部署每个函数实例的冷启动都会重新初始化 Prisma Client。虽然 t3code 的单例模式能减轻连接压力但如果并发量高还是建议给 Neon 的连接串加上连接池参数带着 PgBouncer 的 connection limit 设置可以把连接数控制在一个健康范围内。其次是静态页面与动态数据的区分。如果你用了getStaticProps去拉数据库数据构建时就会执行数据库查询。但 serverless 环境下这个构建实例不一定能访问线上数据库所以我的建议是涉及用户登录后的页面不要做静态生成直接用客户端useQuery或服务端getServerSideProps避免“构建时数据 上线后数据”的错觉也避免一堆难排查的缓存问题。部署完之后一定要做一次“新建用户 → 创建看板 → 拉取看板列表”的完整冒烟测试。很多帖子的教训都是本地一切正常线上第一步登录就挂了九成原因是环境变量没配全或者数据库迁移没应用。7. 问题排查速查表与独家避坑经验7.1 我踩过的高频坑整理一下这几次 t3code 实战里最有代表性的坑如果你也在做类似项目照着这个表排查能省很多时间。问题现象可能原因排查手段pnpm prisma migrate dev报数据库连不上DATABASE_URL 写了 localhost但数据库在远端先确认npx prisma db execute能否连通页面能打开但 tRPC 查询一直 pendingtRPC Router 没注册到 root.ts打开 Network 面板看/api/trpc请求的响应体登录回调报 500NEXTAUTH_SECRET 缺失或太短检查环境变量重新生成秘钥NextAuth 登录后session.user.id是 undefined没有在 callbacks.session 里映射 user.id在src/server/auth.ts里补 session callbackbuild 成功但运行时查不到表线上库没有执行 migrate deploy在部署平台构建命令里加prisma migrate deploy中文内容乱码数据库字符集不是 UTF-8创建库时设置encodingUTF8注意排序规则交互事件不触发可能被 Server Component 水合问题影响在组件顶部加use client指令排查7.2 排查思路与工具技巧过一个坑之后我摸索出一套适合 t3code 的快速定位流程。先用最笨但最有效的办法打开浏览器 DevTools切到 Network 面板看/api/trpc/*请求的状态和响应。tRPC 的错误信息默认会把具体问题带在响应体里比如 “UNAUTHORIZED” 或者 “数据库连接失败”这比看前端控制台里的笼统报错要直观得多。第二步是检查服务端日志。本地跑pnpm dev时如果 tRPC 报错服务端终端里会打印完整的堆栈往往能直接定位到某个 router 方法里出了问题。第三步是确认 Prisma Studio。遇到数据相关的问题我会在项目目录下执行pnpm prisma studio它会起一个本地可视化工具直接把数据库表和数据展示出来。这比 SQL 客户端轻量很多也而不用切窗口。你能很直观地看到数据到底有没有写进去、外键 ID 是否正确至少能做到“程序逻辑问题”和“数据问题”的快速剥离。最后一个建议是养成“改 schema 后立刻重新生成”的习惯。因为 t3code 的类型是层层透传的如果你改了 Prisma 模型却没有执行pnpm prisma generate前端页面拿到的还是旧类型编译可能不报错但运行时行为会和新表结构不一致。最容易漏的就是给表加了字段后忘记生成 Client然后页面里新字段编译器完全不知道只能靠运行时才能发现。我在实际项目里还会额外加一条流水线逻辑每次部署前本地先执行pnpm prisma migrate dev和pnpm prisma generate确保本地生成器和迁移文件都是最新状态再往线上推。这一步虽然简单但能极大减少“本地能跑线上挂”这类问题的概率。如果再让我重新搭一遍 t3code我会在做任何页面之前先把数据库模型和 tRPC Router 层彻底想清楚因为这层定了上层页面几乎是被动消费类型。反过来如果先写页面再去补后端接口就会被类型错误反复拉扯改来改去非常痛苦。核心链路通顺了后续业务的增加真的就是按部就班而已。
阅读完成 · 觉得有帮助?