T3 Stack全栈开发实践:用t3code搭建类型安全的Next.js应用
发布时间:2026/10/27 16:02:05 锦皓数字建站

做技术这几年T3这套东西时不时就会在朋友圈和技术群里刷到。t3code这个项目说白了就是把 Next.js、TypeScript、tRPC、Prisma、Tailwind 这套T3 Stack的标准模板再配上实际业务里经常需要的脚手架、路由、认证和部署配置打包成一个开箱即用的全栈开发起点。最早我是在几个 GitHub 仓库里看到这个词很多人在找t3code相关的模板和最佳实践。它解决的核心痛点很直接一个新的全栈项目从零开始配置 TypeScript、数据库、API 层、UI 框架光是把环境踩平就得花掉一两天时间。而 t3code 这类脚手架要做的就是把这套流程压缩到几分钟让开发者直接进入业务逻辑而不是反复折腾工程配置。这篇文章不打算讲太虚的理论就围绕我实际用 t3code 搭全栈项目的完整过程把选型理由、目录结构、核心代码、部署细节和踩过的坑全部摊开聊。1. 为什么选择 t3code 而不是自己从零搭1.1 全栈 TypeScript 的工程收益先说个经常被忽略的事实团队协作时最大的隐性成本其实不是代码难写而是数据在前后端之间的类型定义失同步。后端模型改了一个字段名前端接口文档没更新等联调的时候才暴露这是我见过最多的返工场景之一。t3code 的核心逻辑就是通过 tRPC 让前后端共享同一套类型推导。很多新手容易理解错以为 t3code 只是把一堆工具放到一个模板里。实际上它设计上有一个明确的价值主张所有链路都跑在 TypeScript 类型系统之上。数据库 schema 由 Prisma 定义生成的类型直接注入 tRPC Router页面上调用 React Query 的时候输入输出类型完全自动推导。换句话说改一处另一处立刻报错从根源上堵住了字段变了但没人知道这个老大难问题。1.2 对比传统 API 方案的取舍在决定用 t3code 之前我做过一个简单对比直观感受一下它和传统 REST API 方案在开发效率上的差异。维度传统 REST Swagger 方案t3codetRPC方案类型安全靠手写接口类型或生成工具经常滞后全自动类型推导一处修改全链路报错联调成本需要前端手动查看文档mock 数据无需文档IDE 提示即真相开发效率每个接口都要定义 route、controller、dto直接在 Router 里写方法逻辑直观适合场景有复杂的对外开放 API 需求前后端同团队维护的 Web 应用当然我不是说 REST 一无是处。如果你要做开放平台、第三方集成REST 依然是更通用的选择。但对大多数内部后台、工具型应用来说tRPC 这套直接写函数的方式明显更加轻快。t3code 把这种模式固化成了标准流程免去了每次都要重新搭建的成本。2. 目录结构与核心技术栈拆解2.1 初始化后的整体布局我拿实际项目举例。用 t3code 创建完成一个项目后看得最多的目录大致如下. ├── prisma │ ├── schema.prisma │ └── migrations ├── src │ ├── components │ ├── pages │ │ └── api │ │ └── trpc │ ├── server │ │ ├── api │ │ │ └── routers │ │ ├── auth.ts │ │ └── db.ts │ ├── styles │ └── utils └── ...这个结构最大的好处是约定优于配置。server 目录下的 db.ts 只负责创建 Prisma Client 单例auth.ts 负责统一导出认证配置api/routers 下每个文件对应一组业务路由。页面组件里通过trpc.xxx.yyy.useQuery()直接调用完全不需要关心 HTTP 层是怎么序列化的。2.2 tRPC 与 Prisma 的联动机制在 t3code 的模板里Prisma 和 tRPC 不是各管各的两套东西而是通过server/api/trpc.ts这个文件打配合。你可以把这里的createTRPCContext理解成一种依赖注入容器每个请求进来先把数据库连接和当前用户会话注入到 context 中然后 Router 里的每个 procedure 都能直接使用这些资源。// src/server/api/trpc.ts import { initTRPC, TRPCError } from trpc/server; import superjson from superjson; import { ZodError } from zod; import { db } from ~/server/db; export const createTRPCContext async (opts: { headers: Headers }) { return { db, ...opts, }; }; const t initTRPC.contexttypeof createTRPCContext().create({ transformer: superjson, errorFormatter({ shape, error }) { return { ...shape, data: { ...shape.data, zodError: error.cause instanceof ZodError ? error.cause.flatten() : null, }, }; }, }); export const createCallerFactory t.createCallerFactory; export const router t.router; export const publicProcedure t.procedure;这段代码看起来不长但很多细节值得细说。superjson这个 transformer 是 t3code 的标配它解决了普通 JSON 序列化无法处理Date、Map、BigInt等类型的问题。如果没有它你从数据库读出一个Date字段传到前端就直接变成字符串需要手动 new Date() 恢复很容易出时区偏差。用 superjson 后类型信息在序列化时会被完整保留前端拿到的直接就是正确的运行时类型。2.3 Zod 与输入校验的整合t3code 对输入校验这件事也做了标准化。以创建用户为例典型写法是在 Router 方法里使用 Zod schemaimport { z } from zod; import { createTRPCRouter, publicProcedure } from ~/server/api/trpc; export const userRouter createTRPCRouter({ create: publicProcedure .input( z.object({ name: z.string().min(1), email: z.string().email(), }) ) .mutation(async ({ ctx, input }) { return ctx.db.user.create({ data: input, }); }), });注意看这里input定义连结了三个层面运行时校验非法请求在进入业务逻辑前就被拦下类型推导input参数在 mutation 回调中自动获得完整类型前端推断调用端直接通过trpc.user.create.useMutation()获取错误反馈这也是我觉得 t3code 在教学和实践上最值得模仿的地方它不是简单地把多个框架堆在一起而是让每一层都服务于类型安全这个目标。3. 从零跑通一个 t3code 项目的实操记录3.1 初始化命令与关键参数我习惯用一个空目录来开始。官方创建命令比较直接npx create-t3-applatest my-t3-app运行后会进入交互式选择界面这里有几个关键选项值得提前想清楚Next.js 版本我建议直接选最新的稳定版App Router 模式一方面官方迭代最活跃另一方面服务端组件和客户端组件的边界在模板里已经处理得比较规范。数据库开发环境快速起步选 SQLite后面要上生产再切到 PostgreSQL。这个选择影响不大因为 Prisma schema 本身是通用的换数据库只需改 provider 和连接串。tRPC / Prisma / NextAuth前期保证默认勾选这样模板才能生成完整的 tRPC 上下文和认证基础代码。如果一开始不选后面手动加会非常痛苦。Tailwind CSS建议保留哪怕你后续要用其他 UI 库。模板里很多示例组件的样式依赖它省事。创建完再启动本地开发cd my-t3-app npm run dev这时候打开http://localhost:3000你看到的默认页面不只是hello world而是包含了一整套 tRPC 健康检查、数据库状态和认证入口的示例站点。我的建议是把这个初始界面当作系统自检来用如果这几个模块都不是报错状态说明环境基本通了。3.2 Prisma 数据模型与迁移流程t3code 默认生成的schema.prisma里已经有一个简单的 User 模型。实际项目里我会在此基础上扩展。比如加一个 Post 模型model Post { id Int id default(autoincrement()) title String content String published Boolean default(false) author User relation(fields: [authorId], references: [id]) authorId Int createdAt DateTime default(now()) updatedAt DateTime updatedAt }然后执行迁移npx prisma migrate dev --name add_post_model这里要特别注意一个反直觉的点prisma migrate dev会同时触发客户端类型生成。如果你在终端里看到Generated Prisma Client字样说明node_modules/prisma/client里的类型已经更新。这时回到代码里写 tRPC Router立刻就能拿到新的db.post属性完全不需要重启 Next.js 开发服务器。不过如果遇到类型没刷新先手动npx prisma generate再重启 dev server这招能解决大部分灵异报错。3.3 写一个完整的业务 Router 示例下面是我在项目初始化后第一个会写的完整业务逻辑用来验证全链路是否顺畅。这个例子是用户创建一篇文章并读取文章列表。// src/server/api/routers/post.ts import { z } from zod; import { createTRPCRouter, protectedProcedure, publicProcedure } from ~/server/api/trpc; export const postRouter createTRPCRouter({ list: publicProcedure.query(async ({ ctx }) { const posts await ctx.db.post.findMany({ orderBy: { createdAt: desc }, include: { author: { select: { name: true } } }, }); return posts; }), create: protectedProcedure .input( z.object({ title: z.string().min(3), content: z.string().min(10), }) ) .mutation(async ({ ctx, input }) { if (!ctx.session?.user) { throw new Error(Not authenticated); } return ctx.db.post.create({ data: { title: input.title, content: input.content, authorId: ctx.session.user.id, }, }); }), });注意我用了protectedProcedure而不是publicProcedure这是模板里预置的一个中间件它会自动检查 session未登录直接抛错。这也是 t3code 比很多手写模板强的地方认证逻辑被抽象成了 procedure 级别而不是在每个业务函数里重复写if (!ctx.session) throw...。前端页面那边地调用也非常简单const utils trpc.useUtils(); const createPost trpc.post.create.useMutation({ onSuccess: () utils.post.list.invalidate(), }); const { data: posts } trpc.post.list.useQuery();这里值得反复体会的是invalidate()这个动作。React Query 在 t3code 里的默认行为是缓存后端数据。当你创建一篇文章后列表不会自动更新必须手动让旧缓存失效。在很多从 setState 思维迁移过来的人看来会觉得多此一举但实际用熟了会发现这是最省心的刷新策略你不需要操心接口返回顺序、防抖、重复请求失效后框架自己会重新拉取。3.4 部署时需要调整的配置项部署是 t3code 这类模板项目最容易翻车的环节。本地跑得好好的一上服务器就报错大多数时候逃不开这几个原因。首先是环境变量缺失。生产环境必须有独立的.env.production或服务器环境变量至少包含DATABASE_URLpostgresql://user:passwordhost:5432/dbname NEXTAUTH_SECRET随便生成一串足够长的随机字符 NEXTAUTH_URLhttps://你的正式域名然后是迁移策略。CI/CD 里我一般会在构建完成之后、启动服务之前执行npx prisma migrate deploy注意这里用的是deploy而不是dev。两者差别非常大migrate dev可能会清空本地数据并触发种子脚本适合开发环境migrate deploy只应用迁移历史不会动已有数据适合生产。这个命令如果写错轻则数据丢失重则导致服务启动失败是必须刻在脑子里的区别。最后是 Next.js 构建时的功能说明。next build阶段Next.js 默认会尝试静态渲染所有页面页面里只要用了useSession或useQuery就要确保在page.tsx里正确声明use client否则会报useSearchParams() should be wrapped in a suspense boundary之类的错。这个问题在 App Router 下尤其常见建议先统一在布局文件里加export const dynamic force-dynamic;等后面真需要静态渲染某些页面时再把这行删掉逐个页面调整。4. 常见问题排查与速查表4.1 tRPC 调用报 404 或 500如果你在页面里调用trpc.post.list.useQuery()返回 404大概率是 API 路由没正确挂载。需要确认src/app/api/trpc/[trpc]/route.ts是否存在并且文件里的 handler 是从~/server/api/root导出的appRouter。一个常见坑是文件路径大小写写错在 Linux 服务器上很导致路由失联。500 错误则多半是 Zod 校验没通过但没在前端拿到具体信息。我的排查方式是直接看服务端终端输出或者临时在错误处理里打error.data。4.2 Prisma 客户端类型没有更新修改schema.prisma之后如果 IDE 里还是找不到新增字段先手动跑一遍npx prisma generate然后重启 TypeScript 语言服务。在 VS Code 里按CtrlShiftP执行TypeScript: Restart TS Server基本能解决。如果还不行检查你是不是开了多个 TypeScript 版本或者项目里有没有本地安装的tsc干扰。4.3 NextAuth 在 App Router 下无法读取 sessiont3code 的逻辑是推荐把 SessionProvider 放在RootLayout里但服务端组件不能直接用客户端 context。模板里通常在src/app/_components/Providers.tsx里封装一个客户端 Provideruse client; import { SessionProvider } from next-auth/react; import { TRPCReactProvider } from ~/trpc/react; export function Providers({ children }: { children: React.ReactNode }) { return ( SessionProvider TRPCReactProvider{children}/TRPCReactProvider /SessionProvider ); }然后在布局里注册。这个方案稳定但要注意TRPCReactProvider内部需要拿到当前请求头所以在 App Router 下它必须被客户端组件包一层不能直接在服务端组件中使用。4.4 Tailwind 样式突然失效这种情况大多是你引入了某个第三方 UI 库而它生成的 CSS 放在自己的包里没有经过 Tailwind 的content扫描路径。解决方式是在tailwind.config.ts的content数组里加上库文件路径content: [ ./src/**/*.{js,ts,jsx,tsx}, ./node_modules/第三库/dist/**/*.js, ],还有一个隐蔽坑如果你打开了浏览器广告拦截插件它可能拦截掉本地加载的字体文件导致图标或特殊字符显示异常这不是代码问题别浪费时间排查。5. 项目扩展思路与个人体会t3code 这套模板用顺手之后我最大的感觉是它把一个团队的默认技术规范实体化了。之前每个新项目都要在技术选型和工程化配置上重新吵一遍现在有了统一起点讨论焦点被迫提前到业务建模和交互设计上。而且因为类型系统贯穿始终新人在 IDE 里写代码时看到的提示几乎就是业务契约本身学习成本和沟通成本都降了很多。我个人的体会是t3code 真正打动我的不是某个单点技术而是它展示了全栈工程化应该长什么样不是把所有流行库塞进 package.json而是通过代码结构和流程约定让类型安全、校验、认证、数据缓存这些横切关注点变成默认遵守的规范。最后再分享一个小技巧。刚开始用 t3code 时很多人会觉得trpc调试很麻烦没有 REST 那样可以直接在浏览器地址栏输入 URL 测试。我的解决方法是写一个叫server.ts的独立脚本通过createCallerFactory直接调用 Routerimport { createCaller } from ~/server/api/root; import { createTRPCContext } from ~/server/api/trpc; const ctx await createTRPCContext({ headers: new Headers() }); const caller createCaller(ctx); const posts await caller.post.list(); console.log(posts);这样既不用启动 HTTP 服务又能享受 tRPC 的类型检查和 Zod 校验排错时比浏览器方便得多。后续如果业务膨胀也可以把额外需要开放的接口迁移到 REST 或 GraphQL反正有了稳定的核心 Router外层适配交给框架就好。t3code 不是银弹但作为全栈项目的起点它确实称得上不用动脑子的可靠方案。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。