☰
用T3 Stack构建类型安全的全栈项目:t3code实战解析
2026/10/27 23:04:46 网站建设 项目流程

全栈项目里最让人上火的从来不是业务逻辑有多复杂,而是前端把user.name拼对了、后端改了个字段名,结果整个页面悄悄变成undefined,调试一晚上发现是类型不同步。这套问题我吃过大亏,后来把项目整个搬到T3 Stack上重写,才真正舒服了。今天聊的就是我这个个人项目t3code——一个基于T3 Stack的代码速记平台,顺便把整套架构从初期选型到上线部署的完整思路、踩过的坑和实际代码姿势全盘托出。

t3code能做的事很简单:登录后你可以创建代码片段、给片段打标签、按标签过滤检索,所有操作都是类型安全的端到端闭环。数据存在PostgreSQL,接口走tRPC,界面用Tailwind撸,认证走NextAuth,部署在Vercel上。如果你正准备搞全栈项目,或者已经听说过T3 Stack但不知道具体怎么搭、怎么调、怎么避免那些文档里不会写的坑,这篇应该能帮你省不少时间。

1. 为什么我最终选择了T3 Stack

1.1 一个全栈项目的核心痛点:类型不同步

先说说我为什么从传统的前后端分离切过来。我之前习惯用REST API + 纯前端框架,每次加一个接口,要么手写一套类型声明,要么靠swagger自动生成,但代码生成和手写类型始终是两套东西。最典型的生产事故就是:后端改了响应结构,前端所有依赖老字段的页面全部静默失败——不会有编译报错,因为运行时才知道字段不存在。

T3 Stack的核心逻辑恰恰是针对这个痛点来的:它把所有类型汇聚在一个地方,前端调用接口、后端返回数据,走的是同一套TypeScript类型系统。你改了Prisma model,tRPC的query返回值类型立刻跟着变,前端写错字段名在编译期就直接红杠杠,根本不会等到浏览器里出bug。

这套组合拳包含五个主要组件:Next.js负责路由和渲染,TypeScript做全链路类型约束,Tailwind CSS处理样式,Prisma充当ORM层,tRPC替代REST写API。T3 Stack里的T3就是指TypeScript、Tailwind、tRPC这三者的合体,名字里顶着T3的t3code,本质上就是这个技术栈的一个完整实践样本。

1.2 T3 Stack到底解决了哪些问题

我实际用下来,T3 Stack解决了三个问题:

类型安全从数据库贯穿到浏览器。Prisma model定义了数据库表结构,这个类型可以直接被tRPC的procedure引用。前端通过tRPC查询拿到的数据,类型是后端自动推导出来的,不需要任何手写interface。我之前在REST项目里那种"手写200行类型还是漏了一个字段"的情况,在t3code里基本不存在,因为类型只有一套,改一处全局生效。

API层不再需要手工维护文档。REST API最常见的麻烦是接口文档和实现代码脱节。tRPC直接把procedure暴露给客户端调用,输入输出类型强制校验,前端调用哪个procedure,参数和返回值类型一目了然。你甚至可以在前端代码里直接Cmd+点击跳到后端handler的定义,追查逻辑比看任何API文档都直观。

一整套脚手架省掉了繁重的初始配置。create-t3-app会帮你把Next.js、tRPC、Prisma、Tailwind、NextAuth全部初始化好,目录结构也是社区验证过的合理划分。不用自己操心tsconfig该怎么配、next.config怎么改、tailwind内容和content glob怎么写。开箱已经有了可运行的hello world,你只需要往里填业务。

1.3 什么项目不适合T3 Stack

也得客观说一下边界。T3 Stack不是万能药,如果你的项目需要开放API给第三方开发者,tRPC这种强耦合模式就不太合适,REST或GraphQL仍然是对的。如果团队前后端明确分开、后端要支持多个客户端,T3的"全栈一体化"优势也可能变成负担。但如果你像我一样,主要做Web应用、团队不大、想在保证开发速度的同时把类型坑填平,T3 Stack就是我目前见过的最优解之一。

2. t3code整体设计与架构拆解

2.1 项目定位与核心功能规划

t3code这个项目的定位是"面向开发者的轻量级代码速记工具",类似一个简化版LeetCode笔记库或者博客的路子,但核心是代码片段管理。

功能清单我在动工之前就列得很清楚,只保留刚需:用户注册和登录、创建/编辑/删除代码片段、按语言分类、标签系统、按关键词搜索、公开页面展示。做这个项目我给自己定的原则是:每个功能必须能证明T3 Stack的一个特性,不为堆功能而堆功能。例如登录用NextAuth才能体现session和tRPC context怎么打通,标签系统是为了验证带有中间表的Prisma多对多关系怎么建模,搜索是为了练tRPC query参数和Zod校验的配合。

整个项目的数据模型比较简洁:User表来自NextAuth的账号体系,Snippet表是核心业务表,Tag表做标签字典,中间表SnippetTag把片段和标签做成多对多关系。这套模型不复杂,但它覆盖了90%全栈项目都会遇到的建模场景,很适合用来学习。

2.2 目录结构逐层讲解

create-t3-app生成的目录结构本身就很有讲究,我把它改造后保持在以下状态:

t3code/ ├── prisma/ │ └── schema.prisma # 数据库模型定义 ├── src/ │ ├── app/ │ │ ├── api/auth/[...nextauth] # NextAuth路由 │ │ ├── layout.tsx # 根布局,全局样式+导航壳 │ │ ├── page.tsx # 首页(公开片段流) │ │ └── snippet/ │ │ ├── [id]/page.tsx # 片段详情页 │ │ └── new/page.tsx # 新建/编辑片段页 │ ├── server/ │ │ ├── api/ │ │ │ ├── routers/ │ │ │ │ ├── post.ts # 片段相关procedure │ │ │ │ └── tag.ts # 标签相关procedure │ │ │ └── root.ts # 根router,聚合所有子router │ │ └── db.ts # Prisma实例 │ ├── trpc/ │ │ ├── server.ts # tRPC服务端初始化、context、中间件 │ │ └── client.ts # tRPC客户端hook封装 │ └── styles/ │ └── globals.css # Tailwind入口 ├── .env # 数据库URL、NextAuth密钥等环境变量 ├── next.config.mjs ├── tailwind.config.ts └── package.json

这个结构最大的特点就是逻辑按"服务端到底层"和"客户端到界面"两条主线组织。src/server/api/routers里每个文件对应业务模块,src/trpc负责服务端初始化和客户端hook,两个方向共用同一套类型。我第一次接触这个目录时觉得有点绕,但写了一个完整模块后就习惯了——它比传统的"controllers/services/models"分层更贴合全栈应用的调用链。

2.3 技术选型细节:为什么每个组件都是它

我根据自己的实际经验逐项说下选型理由,这些理由在官方文档里未必全部写清楚了。

Next.js App Router vs Pages Router:create-t3-app默认让你选,我选了App Router。因为App Router的Server Component天然适合这种内容型页面,可以做到部分数据在服务端取、部分交互在客户端做,而且布局嵌套比Pages Router干净。代价是客户端组件要显式加"use client",一开始容易漏了导致hydration报错,这个我后面单独讲坑。

tRPC vs REST:这个核心抉择我已经说过了,tRPC带来的类型一次编写全局复用的体验,是全栈一体化项目里最爽的部分。但要注意,tRPC不是用来替代REST作为开放API的,它是给"同一个应用的前端和后端"用的内部RPC。

Prisma vs TypeORM/Drizzle:Prisma的schema语法最有亲和力,数据库迁移工具也完善。Drizzle更轻、更贴近SQL,但生态和团队认知目前还是Prisma更成熟。做业务项目优先选Prisma,省心。

Tailwind vs CSS Modules/Sass:我用Tailwind主要看中它的原子化类和响应式断点,写小项目的时候不用频繁切换CSS文件。配上shadcn/ui这类组件库,开发速度肉眼可见提升。Tailwind的坑在内容扫描配置,如果你新增了一个不存在的目录,类名可能不生效,好在create-t3-app的默认配置已经覆盖了src下的所有ts文件。

2.4 数据模型设计背后的思考

Prisma schema的核心部分大致长这样:

model User { id String @id @default(cuid()) name String? email String? @unique image String? snippets Snippet[] accounts Account[] sessions Session[] } model Snippet { id String @id @default(cuid()) title String description String? language String code String userId String user User @relation(fields: [userId], references: [id]) tags SnippetTag[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Tag { id String @id @default(cuid()) name String @unique snippets SnippetTag[] } model SnippetTag { snippetId String tagId String snippet Snippet @relation(fields: [snippetId], references: [id]) tag Tag @relation(fields: [tagId], references: [id]) @@id([snippetId, tagId]) }

设计时的几个关键选择:

  • 主键用cuid()而不是自增int。因为公开场景下自增ID容易被枚举抓数据,cuid更安全一些。
  • SnippetTag中间表用复合主键@@id([snippetId, tagId]),天然保证同一个标签不会重复挂在同一个片段上。
  • User表直接由NextAuth的account关系建立,不需要单独写注册接口,数据库层面只加了email唯一约束。

对于新手,我建议你先别看太多花哨的建模技巧,把这三个最基础的模型关系(一对一、一对多、多对多)练明白,已经能应对绝大多数应用了。

3. 从零搭建t3code的全流程实操记录

3.1 初始化项目与基础配置

我假设你的开发机已经装好了Node.js 18+、git,数据库用的是PostgreSQL(本地装或者直接用云数据库都可以)。第一步是初始化:

npm create t3-app@latest t3code

命令行会让你做几个选择。根据t3code需求,我建议这样选:

  • TypeScript:必须选,这是T3 Stack的地基
  • Tailwind CSS:选,不要犹豫
  • tRPC:选
  • Prisma:选
  • NextAuth:选,登录功能要靠它
  • App Router:选,现在的默认方向

初始化完成后,你的项目已经带了一套示例代码。此时先不要着急改代码,先把数据库跑起来。我本地用的是Docker起PostgreSQL,这是一条省心路径:

# docker-compose.yml services: db: image: postgres:16-alpine restart: always environment: POSTGRES_USER: t3code POSTGRES_PASSWORD: t3code POSTGRES_DB: t3code ports: - "5432:5432"

然后设置.env:

DATABASE_URL="postgresql://t3code:t3code@localhost:5432/t3code?schema=public" NEXTAUTH_SECRET="用openssl rand -base64 32生成一个随机长字符串" NEXTAUTH_URL="http://localhost:3000"

NEXTAUTH_SECRET这个值踩过坑的人应该都懂:如果不设置或者太短,NextAuth在production模式会直接拒绝启动。

接着跑迁移:

npm run db:push # 开发期快速同步,不生成迁移文件

数据库的表就会自动建好。注意我在开发阶段用db:push图快,但要上线前一定要切换成db:migrate来生成正式迁移记录,否则生产库的历史变更就没法追溯了。

3.2 实现第一个tRPC procedure:创建片段

初始化后系统会生成一个示例的post router,我直接在这个基础上改造。首先在src/server/api/routers/post.ts里写创建片段的procedure:

import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "~/trpc/server"; const createSnippetSchema = z.object({ title: z.string().min(3, "标题至少3个字符"), description: z.string().min(10).max(300).optional(), language: z.string(), code: z.string().min(1), tags: z.array(z.string()).optional(), }); export const postRouter = createTRPCRouter({ create: protectedProcedure .input(createSnippetSchema) .mutation(async ({ ctx, input }) => { const snippet = await ctx.db.snippet.create({ data: { title: input.title, description: input.description, language: input.language, code: input.code, userId: ctx.session.user.id, tags: input.tags?.length ? { create: input.tags.map((name) => ({ tag: { connectOrCreate: { where: { name }, create: { name }, }, }, })), } : undefined, }, }); return snippet.id; }), });

这里有一个关键点要说明白:为什么要用protectedProcedure而不是publicProcedure。创建一个资源必须知道是哪个人创建的,所以这个procedure必须要求用户登录。protectedProcedure是create-t3-app从createTRPCInner里派生的一个中间件,它会自动读session,如果没有登录就直接抛401错误。

另一个点是标签的connectOrCreate。当用户提交标签时,如果标签已经存在,就连接已有记录;不存在就创建。这个写法完美避开了先查一次、再插入一次的两步逻辑,一条Prisma create就能搞定。

3.3 前端表单与tRPC hook的联动

新建片段页是典型的客户端交互页面,所以需要加上"use client"标记。然后调用tRPC的hook:

"use client"; import { useState } from "react"; import { useRouter } from "next/navigation"; import { api } from "~/trpc/client"; export default function NewSnippetPage() { const router = useRouter(); const utils = api.useUtils(); const [title, setTitle] = useState(""); const [description, setDescription] = useState(""); const [language, setLanguage] = useState("typescript"); const [code, setCode] = useState(""); const [tags, setTags] = useState(""); const createSnippet = api.post.create.useMutation({ onSuccess: (id) => { // 清理缓存,让列表页立刻刷新 utils.post.getAll.invalidate(); router.push(`/snippet/${id}`); }, onError: (error) => { alert(error.message); }, }); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); createSnippet.mutate({ title, description, language, code, tags: tags.split(",").map((t) => t.trim()).filter(Boolean), }); }; return ( <form onSubmit={handleSubmit} className="mx-auto max-w-2xl space-y-4 p-8"> {/* 表单控件 */} </form> ); }

这里有个实操体会:useMutation返回的error里,可以透出后端Zod校验的详细信息,比如标题太短的提示。用户在前端看到的是后端校验消息,就不需要前端再写一套校验逻辑了,这是tRPC"单源校验"带来的红利。注意,前端仍然可以做基础的required属性防止空提交,但深层的业务规则完全由后端zod schema负责。

3.4 查询列表与Server Component配合

列表页我用Server Component在服务端直接取数,页面渲染和SEO都能兼顾。做法是直接创建一个server侧的调用器:

// app/page.tsx import { api } from "~/trpc/server"; export default async function HomePage() { const snippets = await api.post.getAll({ limit: 20 }); return ( <main> <h1>最新代码片段</h1> <SnippetList snippets={snippets} /> </main> ); }

一开始我觉得在Server Component里调tRPC不太习惯,但后来发现这个模式的体验非常顺:同一个procedure既能当REST接口给客户端用,也能当服务端函数直接调用,数据获取逻辑完全不用重写。各类权限校验、数据库访问都在procedure内部完成,页面层只管拿数据。

这里要提醒一个重要细节:Server Component里如果某个procedure依赖session,请确认NextAuth的sessionProvider已经正确放进App Router的context里。create-t3-app的Provider组件会帮你包一层,但实际上server端读取session是独立的逻辑,我遇到过因为没写对auth()导致Server Component拿不到登录态的情况。

3.5 部署流水线:Vercel + 云数据库

项目开发完,部署到生产环境。我用的方案是Vercel托管前端服务,数据库用Neon的免费PostgreSQL实例。

部署步骤:

  1. 把代码推到GitHub仓库
  2. 在Vercel导入仓库,framework自动识别Next.js
  3. 在Vercel的环境变量面板里配置DATABASE_URL、NEXTAUTH_SECRET、NEXTAUTH_URL
  4. 构建命令保持默认,Vercel会执行npm run build,这个脚本在create-t3-app里已经包含prisma generate+next build
  5. 部署后访问生成的域名

部署t3code时我踩了一个隐蔽的坑:本地开发时NEXTAUTH_URL是http://localhost:3000,换到Vercel后忘了改成正式域名,导致登录后回调地址永远指向localhost,一脸懵地看到页面跳转失败。所以每当切换环境,第一件事检查所有环境变量。另外,Prisma在Vercel的构建阶段需要能访问数据库实例吗?答案是构建时不需要连数据库,但prisma generate需要读schema文件,所以构建依赖的是Schema定义不是DB本身。生产runtime才真正连数据库。

4. 实际线上踩过的坑与排查路线

4.1 表单校验收不到错误信息的经典坑

有段时间我在create片段时,input长度小3个字,后端Zod应该报错,但前端弹窗一直显示Unexpected end of JSON input而不是业务提示。查了半天,发现是createTRPCProxyClient的httpBatchLink的batch模式在作怪——批量请求时多个请求被合并在一个HTTP request里,如果其中一个procedure抛错了,响应流格式会变成非标准JSON,导致客户端解析失败。

解决办法有两条:一是把httpBatchLink换成httpLink,禁用批处理,代价是每个请求多花一点HTTP开销;二是在前端拦截器层级做统一错误处理,不要依赖单个请求的JSON body取message。我个人倾向于方案二,因为批处理对性能友好,错误处理本来就该单独抽象。

4.2 NextAuth登录后session不刷新的问题

用Google OAuth登录t3code后,页面上的用户信息没有立刻更新,需要刷新一次才正常。最初我以为是客户端缓存问题,后来发现是useSession没有感知session更新。NextAuth的SessionProvider里需要正确设置refetchInterval或者监听onSessionChange事件。

我最终采用的方案是:

<SessionProvider refetchInterval={5 * 60}> {children} </SessionProvider>

再配合tRPC的useUtils().session.refetch()在关键操作后手动刷新。另外,因为tRPC的context里会读session,session变化还可能导致旧缓存数据残留,这就必须把session相关查询也加进invalidation范围。

4.3 Prisma连接数打满的排查

项目在某天下午突然大量请求报错P1001: Can't reach database server。一开始怀疑数据库挂了,检查Neon控制台发现连接数已经打满。排查后发现原因:Server Component里每个页面请求都会new一个PrismaClient实例,而Vercel的serverless函数会在多个并发请求下同时创建数据库连接,连接池迅速被撑爆。

正确做法是使用Prisma官方推荐的全局单例模式,create-t3-app生成的src/server/db.ts本身就是单例。但问题是,如果你在某个组件里直接import { PrismaClient } from '@prisma/client'而不是从~/server/db导入,就会创建新的实例。排查方法很简单:全局搜索new PrismaClient,确认所有数据访问都从单例走。我当时就在一个工具函数里偷偷new了一个,差点把人搞疯。

提示:遇到诡异的数据库连接错误,第一反应先数一下代码里你能找到几个new PrismaClient。正确的做法永远只有一处。

4.4 Tailwind类名失效

新增了一个src/lib目录,在里面写组件时发现className="flex items-center space-x-2"完全没生效。Tailwind的内容扫描默认配置是./src/**/*.{ts,tsx},按理说能覆盖到src/lib,但实际检查发现,create-t3-app为了优化编译性能,tailwind.config的content数组里明确列出了几个目录,新目录不会被自动扫描进去。

解决办法是手动维护tailwind.config.ts的content数组,把所有可能涉及类名的目录都加进去。这一点小事卡了我将近一小时,原因是完全没有报错,样式就是不出来。

4.5 t3code在移动端的适配

最后提一个非技术但很实际的问题:代码片段适合横屏阅读,手机上竖屏看长代码特别痛苦。我给代码块做了横向滚动容器,而不是强制缩放字体,这样在手机上阅读体验会好很多。这个细节虽然简单,但直接影响用户留存率。

5. 实战中我觉得最有用的几个经验

做了t3code这个项目后,我对全栈开发的体会比之前做REST项目时深了一大截。这里分享几个我认为真正值钱的经验,也是网上没有任何文档会讲的东西。

第一,类型安全带来的重构安全感。我后来给t3code加了一个"复制代码"按钮,需要给Snippet的返回对象加一个字段。如果是REST项目,我还得担心前端所有用到Snippet的地方要不要改。但在t3code里,我只改了Prisma schema和手写了一个SELECT字段,编译一跑,所有用到旧类型的地方全部标红,逐个修改就好。这种体验用一次就回不去了。

第二,开发时要善用tRPC的端到端类型推导来倒推数据库设计。我先写前端页面想要的UI组件形状,再倒推tRPC procedure的返回类型,再倒推Prisma model。这样整个开发流程几乎不会出现"页面需要数据A,接口却只给了数据B"的返工。

第三,关于权限,不要自己手写中间件。create-t3-app自带的protectedProcedure已经足够通用,不要想着自己写一套角色权限系统。我在t3code早期自己搞过一套基于session角色字段的权限判断,结果维护了一周就发现,业务里的权限组合根本不需要那么复杂,把所有写操作都收进protectedProcedure,把读操作分成public和protected两类,已经能覆盖99%的场景。自己造轮子只会增加出bug的机会。

第四,t3code后续的扩展方向我也规划了一些。比较有价值的功能是:分享片段生成公开链接、支持代码高亮主题切换、加入全文搜索。这些如果做成系列文章,我可能会把"如何在自己项目里集成全文搜索引擎"单独写一篇,因为那又是一套完全不同的权衡。

如果你也想照着这个思路做一个全栈项目,我建议第一步不必急着模仿t3code的功能,而是先跑通"创建一个带登录的页面,通过tRPC写一条数据进数据库,再在列表页显示出来"这个最小闭环。这个闭环跑通后,剩下的功能只是一步步往这个模式里填,风险和阻力比一上来就做大而全的系统要小得多。我自己写t3code最大的一次教训,恰恰是最开始太着急搭全功能,后来才退回来把基础链路理干净。希望这篇能给你的项目省下那几个我折腾过的不眠夜。

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

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

立即咨询