用 T3 Stack 构建类型安全的全栈应用:从数据库到前端完整实战 1. t3code 项目定位这不是玩具是一个能上线的全栈样板先说结论t3code 是我用 T3 Stack 做的一个轻量级代码片段管理工具也可以理解为一套“照着抄就能跑”的全栈实战代码库。T3 Stack 指的就是这几年 TypeScript 全栈圈里很流行的那套技术组合——Next.js 做前端页面和 API 层TypeScript 保证类型安全tRPC 替代传统 REST 接口Prisma 负责数据库建模与访问Tailwind 解决样式问题。项目名叫 t3code实际上就是“T3 技术栈下的代码实战”这个意思我把常见的代码片段保存、标签分类、全文检索、用户鉴权这些功能都做进去最终你拿到的不只是一堆散落的组件而是一条从数据库到页面、从登录到部署的完整链路。这类项目最大的价值在于它把全栈开发里最容易被忽略的“边界问题”一次性讲清楚了哪些代码该写在服务端哪些逻辑必须经过鉴权前端组件到底怎么拿到数据改了数据库表之后哪些类型会跟着变。很多新手自己用 Next.js 写项目写着写着就变成“所有逻辑堆在页面上”或者“为了调一个接口写一百行 useEffect”这些问题在 t3code 里基本不存在因为技术选型本身就强制你走在正确的轨道上。如果你是一个想快速上手全栈开发的 JS/TS 工程师或者是一个需要给团队做内部工具、管理后台的独立开发者这个项目的参考价值很高。反过来如果你的诉求是纯后端服务或者项目规模大到必须拆微服务、需要独立部署的 API 网关那 t3code 的体量确实不适合你它更适合“一个应用搞定业务”的场景。我后面会从技术选型的逻辑讲起再到数据库建模、tRPC 路由设计、前端调用方式最后把我实际踩过的坑和排查思路一并写出来。这不是一篇概念科普我会把能直接复制的代码和配置都放出来你照着操作大概率能在一个晚上跑通整个项目。2. 技术选型背后为什么把这四样东西焊在一起2.1 类型安全贯穿整条链路这是 T3 的核心红利做全栈开发最烦躁的一件事是什么是后端接口返回的数据结构和前端页面上用的类型对不上。后端改了字段名前端没跟着改运行时才报错甚至运行时不报错但页面上显示 undefined排查半天发现是拼写不一致。T3 这套组合解决的就是这个痛点。Prisma 定义好数据库表结构之后会自动生成对应的 TypeScript 类型tRPC 的 router 里声明的输入输出类型会在前端调用时被完整推断出来Next.js 组件里用api.snippet.getAll.useQuery()拿到数据这个数据的类型就是后端 router 返回的类型中间没有任何手写的 axios 封装、没有手写的 interface、没有 codegen 之类的额外步骤。我见过很多团队在 REST 架构里做类型管理普遍方案是 OpenAPI 文档加自动生成客户端链路很长生成出来的代码也不够优雅。tRPC 的思路完全不同它把 API 定义为 TypeScript 函数类型本身就是协议。你改了一个字段前端代码里立刻标红这种“改动即报错”的体验你用过一次就很难回去。2.2 Next.js 不只是前端框架它还是巧妙的服务端入口t3code 里的所有页面都是 Next.js App Router 写的但 Next.js 在这里承担的远不止渲染。它的 Route Handler 能力让 tRPC 的 HTTP 接口可以直接挂载到app/api/trpc/[trpc]/route.ts上前端发起的请求会先经过这个入口再进到 tRPC router 里执行对应的 procedure。等于说整个应用的“接收请求”和“返回数据”都由 Next.js 承担不需要再单独起一个 Node 服务。这个设计对部署特别友好。我早期用 Express 后端加 React 前端做项目每次部署都要维护两个进程前端构建完丢到 CDN后端还要处理跨域、CORS 预检、JWT 分发。t3code 是单一应用部署到 Vercel 或者随便一台支持 Node 的服务器上一条命令构建完所有 API 和页面同时在跑。对小团队和独立开发者来说减少一个服务的运维成本是多重要的事做过的人都懂。2.3 tRPC 与 zod把接口入参校验变成一件轻松的事接口开发里最无聊但最不能省的工作就是参数校验。传统写法是在每个 handler 里手动if (!body.title) return res.status(400)写多了全是重复劳动。t3code 里所有 procedure 的输入都交给 zod 做声明式校验router 定义的时候直接写成createSnippet: protectedProcedure .input( z.object({ title: z.string().min(1).max(100), code: z.string().min(1), language: z.string().default(plaintext), tags: z.array(z.string()).optional() }) ) .mutation(async ({ ctx, input }) { // 这里拿到的 input 已经是完整校验过的 })zod 校验失败时tRPC 会自动把具体的错误字段、错误原因返回给前端。前端做表单时可以直接从error.data.zodError里取出path和message来渲染错误信息不需要在后端再写一套返回格式。这相当于把“校验”和“错误处理”的成本从每个接口里抽出来统一交给框架层解决非常省心。2.4 Prisma 与 Tailwind 的组合让人舒服Prisma 的价值不只是自动类型它的迁移工作流也值得一提。prisma migrate dev会把 schema 的修改抽象成 SQL 迁移文件数据库结构变化被管理起来多人协作时不会出现“谁改了生产库但没人知道”的惨剧。Tailwind 则纯粹是从效率考虑代码片段管理工具本身不会有特别复杂的视觉设计用原子类写完就能看效果不用维护一整套样式文件。我做技术选型时认真对比过替代方案比如用 Drizzle 代替 Prisma、用 TanStack Query 代替 tRPC 的一部分能力但综合开发体验、生态成熟度、类型推断完整度之后T3 这套组合仍然是最稳的。它不是所有场景里性能最强的但是“开发效率、类型安全、上手门槛”这三点平衡得最好的。3. 核心设计与数据建模先把表结构想清楚后面少走一半弯路3.1 业务模型概览t3code 作为一个代码片段管理工具核心业务其实就三块用户管理、代码片段、标签。为了说清楚多对多关系、鉴权边界这些核心点我在项目里设计了三个主要模型外加一个自动生成的Session和Account表NextAuth 的 Prisma Adapter 会自动创建。model User { id String id default(cuid()) name String? email String? unique emailVerified DateTime? image String? snippets Snippet[] tags Tag[] } model Snippet { id String id default(cuid()) title String code String db.Text language String default(plaintext) isPublic Boolean default(true) createdAt DateTime default(now()) updatedAt DateTime updatedAt authorId String author User relation(fields: [authorId], references: [id], onDelete: Cascade) tags Tag[] } model Tag { id String id default(cuid()) name String userId String user User relation(fields: [userId], references: [id], onDelete: Cascade) snippets Snippet[] }这里有几个设计点值得展开说说。3.2 为什么使用复合型设计而不是过度拆分我看到很多新手做类似项目时会把用户、代码片段、标签表拆得非常散甚至给每个表都加上createdBy、updatedBy这类字段。实际上对于代码片段这种轻量业务Tag直接挂在User下面再通过多对多关联关联到Snippet已经足够满足“我的标签”、“片段打标签”这两个核心需求。如果未来要做“所有人共享标签”的功能再改成全局表也不迟前期不需要为不存在的复杂度做设计。onDelete: Cascade这个设置也很重要。用户删号时他写的所有片段和标签应该一并删除否则数据库里会残留大量孤儿数据。这类关系策略应该在 schema 里显式声明而不是依靠业务代码里手写删除顺序。3.3 创建数据库表之后要做的两件事写完 schema 后不要忘记执行迁移并重新生成客户端npx prisma migrate dev --name init npx prisma generate第一行命令会创建迁移文件并同步数据库第二行会重新生成 Prisma Client 的类型。很多类型报错都是因为改了 schema 后没有执行generate导致前端代码里生成的类型还是旧的。在这里我也养成了一个习惯每次打开项目编辑代码之前先跑一次npx prisma generate确保类型是最新的。虽然这有些强迫症但确实能避免很多莫名其妙的红波浪线。4. 实操过程把 t3code 从一个空项目完整搭起来4.1 初始化项目强烈建议使用官方脚手架不要自己从零拼装npm create t3-applatest t3code交互式命令行会让你选择要包含的模块。我选择的配置是Next.jsApp Router、TypeScript、Tailwind、tRPC、Prisma、NextAuth。选完之后项目目录会自动生成一套完整的文件结构比手动配置靠谱得多。初始化完成后的目录结构大概是这样的prisma/ schema.prisma src/ app/ api/trpc/[trpc]/route.ts layout.tsx page.tsx components/ server/ api/ routers/ root.ts trpc.ts auth.ts db.ts这里最需要关注的是src/server/api目录。trpc.ts负责创建 tRPC 实例和公共流程root.ts把各个业务 router 合并成一个根 routerrouters/里放每个业务域的具体实现。整套架构把服务端逻辑和前端组件区分得非常清楚前端代码完全接触不到数据库访问对象只能通过 tRPC 调接口。4.2 设计数据库模型并完成迁移我按照上一节说的 schema 设计创建好 User、Snippet、Tag 三个模型后执行npx prisma migrate dev --name init如果这是第一次在本地使用需要先确保.env文件里有正确的DATABASE_URL。T3 脚手架会在初始化时让你填写默认是 PostgreSQL 的地址。本地开发我建议直接用 Docker 起一个 PostgreSQL 实例docker run --name t3code-postgres -e POSTGRES_PASSWORDpostgres -p 5432:5432 -d postgres:16然后把.env里的DATABASE_URL改成DATABASE_URLpostgresql://postgres:postgreslocalhost:5432/t3code?schemapublic迁移完成后可以用npx prisma studio打开可视化面板确认三张表都生成成功再继续写业务代码。4.3 实现代码片段的增删改查这是项目的核心部分。我先在routers/snippet.ts里定义业务路由再挂载到根 router 上。首先是 tRPC procedure 的定义。为了让所有片段操作只针对当前用户的数据我使用protectedProcedure。这个 procedure 会先检查ctx.session是否存在不存在就直接抛出未授权错误后面的代码根本不会执行import { z } from zod; import { createTRPCRouter, protectedProcedure } from ../trpc; export const snippetRouter createTRPCRouter({ getAll: protectedProcedure.query(async ({ ctx }) { return ctx.db.snippet.findMany({ where: { authorId: ctx.session.user.id }, orderBy: { createdAt: desc }, include: { tags: true } }); }), getById: protectedProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) { return ctx.db.snippet.findFirst({ where: { id: input.id, authorId: ctx.session.user.id }, include: { tags: true } }); }), create: protectedProcedure .input( z.object({ title: z.string().min(1).max(100), code: z.string().min(1), language: z.string().default(plaintext), isPublic: z.boolean().default(true), tagNames: z.array(z.string()).default([]) }) ) .mutation(async ({ ctx, input }) { const snippet await ctx.db.snippet.create({ data: { title: input.title, code: input.code, language: input.language, isPublic: input.isPublic, authorId: ctx.session.user.id, tags: { connectOrCreate: input.tagNames.map((name) ({ where: { id: ${ctx.session.user.id}_${name} }, create: { id: ${ctx.session.user.id}_${name}, name, userId: ctx.session.user.id } })) } }, include: { tags: true } }); return snippet; }) });注意这里有个小设计为了让 Tag 的where能唯一定位到记录我把 Tag 的id设计为用户 ID 加标签名的复合值这样在connectOrCreate时不需要额外做查重逻辑。当然如果你更倾向于用自增 ID 配合userId name的唯一约束也是可行的只是代码会多一些查询步骤。然后在root.ts里挂载这个 routerimport { createTRPCRouter } from ./trpc; import { snippetRouter } from ./routers/snippet; import { authRouter } from ./routers/auth; export const appRouter createTRPCRouter({ snippet: snippetRouter, auth: authRouter }); export type AppRouter typeof appRouter;appRouter的类型会被前端调用时自动引用这也是类型安全能“贯穿到组件里”的关键。4.4 前端的调用方式与页面展示在 t3code 的列表页里我直接用 tRPC 的 React Hooks不需要写 fetch、不需要管请求状态甚至不需要手动写 loadinguse client; import { api } from ~/trpc/react; export function SnippetList() { const { data: snippets, isLoading } api.snippet.getAll.useQuery(); if (isLoading) return div加载中.../div; return ( ul {snippets?.map((snippet) ( li key{snippet.id} h3{snippet.title}/h3 precode{snippet.code}/code/pre div {snippet.tags.map((tag) ( span key{tag.id}{tag.name}/span ))} /div /li ))} /ul ); }useQuery内部已经封装了请求、缓存、标签数据的逻辑而且返回的snippets数组类型和后端 router 的返回类型完全一致。如果我在后端把返回值字段名改了这个页面上立刻会标红因为 TypeScript 能感知到类型已经变了。新增片段的表单就更直接了用useMutationconst utils api.useUtils(); const createMutation api.snippet.create.useMutation({ onSuccess: () { utils.snippet.getAll.invalidate(); } }); // 提交时调用 createMutation.mutate({ title: form.title, code: form.code, language: form.language, tagNames: [ts, trpc] });invalidate()会重新拉取列表数据页面自动更新。整个流程里我几乎没写过一行手工状态管理代码这是 tRPC 这类端到端类型安全框架带来的实际体验提升。4.5 鉴权绑定与数据隔离NextAuth 的标配配置在src/server/auth.ts里我的做法是先把用户 ID 注入 tRPC 的 context后续所有protectedProcedure都能从ctx.session.user.id拿到当前用户。这样在查询数据时强制加authorId过滤前端永远不能通过修改参数访问别人的数据。具体的 context 注入逻辑如下import { getServerAuthSession } from ~/server/auth; import { db } from ~/server/db; export const createTRPCContext async (opts: { headers: Headers }) { const session await getServerAuthSession(); return { db, session, ...opts }; };然后把protectedProcedure定义成先检查 session、再放行的中间件export const protectedProcedure t.procedure.use(({ ctx, next }) { if (!ctx.session?.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { ...ctx, session: ctx.session as SessionWithUserId } }); });这样每个需要登录的接口只需要在 router 定义时使用protectedProcedure鉴权逻辑就自动生效。做数据隔离的核心不是“藏”起来而是每个查询条件里都带上authorId这两层缺一不可。5. 常见问题与排查技巧我在 t3code 里踩过的坑5.1 最常见问题速查表现象可能原因解决办法前端调用api.snippet.getAll报类型错误修改后端返回结构后前端类型还没重新推断检查src/trpc/react.ts引用的AppRouter是否来自root.ts修改完 router 后重启 dev serverPrisma 操作时报P2021表不存在数据库迁移没执行运行npx prisma migrate devDATABASE_URL报错、连接失败本地 PostgreSQL 没启动或端口不对用 Docker 起服务确认.env里的地址和端口一致NextAuth 登录后 session 一直是 nullNEXTAUTH_SECRET未设置或AUTH_TRUST_HOST配置遗漏在.env里设置随机字符串并用openssl rand -base64 32生成修改 schema 后类型先报错生成类型没更新运行npx prisma generate用useQuery拿到的是 undefined路由写错了或外层缺少SessionProvider检查_app.tsx里是否包裹了TRPCReactProvider和SessionProvider确认查询路径与 router 定义一致5.2 我踩过的三个比较隐蔽的坑第一个坑是改完 schema 忘记跑迁移和生成。有一次我在 Prisma Studio 里手动加了一个测试数据然后把Tag模型的id类型从自动生成改成了复合字符串结果忘了重新迁移。前端代码看起来逻辑没问题但一启动就报 P2021花了大半夜排查最后发现是迁移记录没有提交数据库表结构和代码里的模型对不上。第二个坑是在服务端组件里直接调用了db.snippet.findMany()绕过了 tRPC导致鉴权变成摆设。App Router 的 Server Component 写起来很方便但这绝不能成为绕过接口层的理由。t3code 约定任何数据库访问都必须发生在 tRPC router 内部前端组件无论是什么形式只允许调用api对象。后来我把这个约束写成了项目 README 的第一条规则团队协作时少了很多争论。第三个坑和 NextAuth 的回调地址有关。本地开发用http://localhost:3000没问题但一旦把项目部署到线上如不设置AUTH_URL之类配置NextAuth 会把回跳地址指向默认域名导致登录跳转循环。解决方法是把部署环境的真实域名配置到环境变量里并在auth.ts的providers里显式声明。5.3 几个值得长期坚持的习惯每次打开编辑器先跑一遍npx prisma generate有奇效使用superjson作为 tRPC 的 transformer它会自动处理 Date 类型避免Date对象序列化后变成字符串的问题业务上有复杂的筛选条件时优先在 tRPC router 里用input zod做约束不要在前端拼 SQL 条件这些习惯不会让你的代码突然变得很高级但能显著降低全栈联调时的摩擦。尤其是 superjson 这一点很多第一次用 tRPC 的人都会踩到“查询出来的createdAt变成了字符串”这个坑。6. 部署上线与扩展思路项目本地跑通后部署到线上并不复杂。因为 t3code 是一个标准的 Next.js 应用我推荐直接部署到 Vercel 之类的 Node 平台或者使用自己的服务器跑npm run build npm start。在部署之前务必检查这三样东西DATABASE_URL 必须指向生产数据库如 PostgreSQL 云数据库 NEXTAUTH_SECRET 使用随机的长字符串不要沿用本地开发值 NEXTAUTH_URL 设置为线上域名否则 OAuth 登录会失败如果你的代码托管在 GitHub 上还要注意不要把.env文件提交上去。T3 脚手架默认生成.env.example就是用来做参考的把真实环境变量配置到部署平台的环境变量面板里即可。数据库迁移在生产上的正确姿势是prisma migrate deploy而不是prisma migrate dev。dev会尝试重置数据生产环境绝对不能用。整个项目做完之后我最大的感悟是t3code 表面上是一个代码片段管理工具实际上是一个“类型安全全栈开发”的示范载体。你把这套东西吃透了之后再去做内容管理、后台系统、内部工具基本上都是同一个套路用 Prisma 定模型用 tRPC 定接口用 Next.js 画页面。如果你个人偏好更激进一点的玩法后续还可以尝试把文件上传、AI 代码解释、分享链接之类的功能加进去扩展性也完全够。