☰
t3code全栈开发实践:端到端类型安全从设计到部署
2026/10/27 6:56:31 网站建设 项目流程

t3code 是我最近利用晚上和周末时间做完的一个全栈练手项目,仓库名直接就叫 t3code。名字没有太深的学问:t3 指的是社区里流行的 T3 Stack,也就是 TypeScript、Tailwind CSS、tRPC 这套组合,code 就是代码本身。这个项目最终做成的是一个极简内容发布站:登录用户可以写文章、管理内容,游客可以浏览已发布文章的列表和详情页。它没有复杂的业务,却完整地覆盖了一条从数据库表结构设计到前端组件渲染的现代全栈链路。

如果要说这个项目最有价值的点,不是页面有多好看,而是它向我证明了:在 TypeScript 项目里,数据库查询结果、接口参数、前端状态三者的类型可以真正对齐。以前写全栈项目,数据库一个查询返回什么类型,全靠人肉记着,前端再用 any 兜着;t3code 这套写法能从 Prisma Schema 一路把类型推导到 React 组件,业务代码里几乎看不到手写 interface。

如果你是正在学 Next.js、想了解 tRPC 和 Prisma 怎么配合的人,或者你手里正有一个小项目想用尽量少的时间安全上线,这篇记录应该能直接当参考。下面我会按“技术选型—初始化—核心功能—部署排查—维护经验”的顺序,把 t3code 从零到部署的完整过程讲清楚。

1. t3code:一个小而完整的全栈项目,到底做了什么

先交代一下项目最终形态,不然后面讲技术选型容易变成空中楼阁。t3code 不是一个企业级后台,也不是一个炫技作品,它就是“一个人维护的轻量内容发布系统”。我可以登录账号,写一篇带标题、摘要、正文的文章,设置自定义链接名称后发布;访问者打开首页能看到按时间倒序排列的文章列表,点进详情页阅读完整内容。整个开发过程涉及的模块不算多,但每一层都是真刀真枪跑过的。

1.1 为什么一个练手项目值得写这么多

很多人觉得练手项目不值得记录,但我持相反观点:一个项目如果只是跟着模板点几下、文档抄一遍,那遇到真实问题时的处理过程才是最有价值的部分。t3code 不是大型系统,但它在开发中遇到的类型报错、数据库时区、环境变量缺失、部署后登录状态失效等问题,恰恰是小团队和个人开发者最容易碰到的。把这些过程写下来,比单纯列一份技术清单有用得多。

我见过太多新手项目死在“第二步”:模板跑起来了,但一改代码就开始报错,不知道类型从哪来、数据库连接串怎么配、为什么登录后刷新就掉线。这篇记录更像是“一个人在前面踩坑,把路标留下来”。

1.2 t3code 的功能清单与最终形态

项目的功能范围我刻意压得很小,所有需求都围绕“内容发布”这一个闭环:

  • 基于 Next.js App Router 的多页面内容站点,包含首页、文章详情页、写作页。
  • 使用 NextAuth 接入邮箱账号登录,登录后才能进入写作页。
  • 登录用户可以创建文章、编辑标题与正文、发布或下架内容。
  • 游客可以在首页看到“已发布”的文章列表,通过 /post/[slug] 访问详情页。
  • 数据库采用 PostgreSQL,通过 Prisma 维护 schema 与迁移文件。
  • 全站使用 Tailwind CSS 做样式,没有引入组件库,尽量保持依赖精简。

这个功能清单看起来平凡,但“平凡”本身就是刻意选择:几乎所有业务系统最核心的部分都是 CRUD,能最大限度测试 tRPC 的 query、mutation 和鉴权逻辑。如果一开始就追求复杂业务,反而很难看清技术栈本身的边界。

1.3 这篇记录里你能直接拿走的东西

我不想写一篇“项目晒图”式的文章,所以把可复用的内容提前列出来:

  • create-t3-app 初始化的完整命令和交互参数。
  • Prisma 模型设计里字段取舍的实际理由。
  • tRPC router 的典型写法和 zod 输入校验的落地方式。
  • 前端 useQuery、useMutation 的使用细节,包括常见误区。
  • 部署到 Vercel 时环境变量、构建命令、数据库迁移的配合方式。
  • 一张高频问题速查表,以及我实际踩过的三个坑。

下面的内容都来自 t3code 的开发实录,不会只有概念没有操作。

2. 技术选型解析:为什么会选 TypeScript、tRPC、Prisma 这条链路

在动手写代码之前,我花了两个晚上重新梳理技术栈。选择 T3 Stack 看起来是“跟风”,但深入了解之后发现,这套组合解决的核心问题很具体:类型安全能不能从数据库一路贯穿到浏览器?答案是能,而且比我想象中彻底。

2.1 T3 Stack 不是框架,是一条“契约链”

T3 Stack 不是一个新框架,它更像一组约定:Next.js 负责页面和路由、TypeScript 负责类型、Tailwind CSS 负责样式、tRPC 负责服务端方法调用、Prisma 负责数据库访问,可选加上 NextAuth 做鉴权。把这几个工具串起来之后,会形成一条“契约链”:Prisma Schema 定义了数据库表结构,生成的 Client 类型决定了 tRPC 路由里能查什么;tRPC 的 router 返回值又决定了 React 组件里能拿到什么。

这条链最关键的地方在于:中间不需要手写任何接口文档。写 REST API 时,我要在控制器里定义返回结构、在函数文档里标注字段类型、在前端再用 interface 重新声明一遍。三处代码只要有一处没同步,运行时就会出错。t3code 里同样一个查询,从 Prisma 到 tRPC 再到 React 组件,类型全部自动推导,字段改名后 IDE 会明确告诉你哪里需要跟着改。

2.2 tRPC 与 REST API 的差异

tRPC 最大的特点是“不生成 API,而是直接调用服务端函数”。它做的事情看起来像 RPC,但底层仍然走 HTTP,只是在开发体验上跳过了 URL 设计和序列化层。我后来整理过一个对比,帮助自己判断什么时候该用 tRPC、什么时候该老老实实写 REST:

对比项tRPCREST API
请求定义直接调用api.post.list.useQuery()手动拼接 URL、method、body
类型安全输入输出自动推断,前后端共享需要额外维护类型或生成 client
文档需求低,路由签名即文档高,需要 OpenAPI 等方案
适用场景前后端同仓库、同语言多端接入、开放平台、第三方需要标准接口

t3code 这种“一个 Next.js 应用内部两两配合”的场景,tRPC 明显更轻。不过我也要承认:如果以后要做移动端 App,或者对外开放 API,tRPC 的生态相对 REST 会受限。技术选型没有银弹,只有适不适合当前项目规模。

2.3 Prisma 让数据库操作“有类型”

Prisma 的核心工作方式是用 schema 文件定义模型,再根据模型生成类型安全的 Client。在 t3code 里,我写了一个 Post 模型,然后调用ctx.db.post.findMany()时,返回结果的每一项都自动包含 Post 模型定义的所有字段。前端组件里直接post.title、post.slug,编辑器会立刻提示字段名是否正确。

这种体验对团队协作的收益尤其明显。以前查数据库结果,字段写错是运行时才发现;现在写错字段,编译阶段就报红。Prisma 的迁移文件也保留了数据库结构变化历史,比直接执行 SQL 语句更容易 review。

2.4 为什么没有选 GraphQL 或纯 REST

我也考虑过 GraphQL,它有类型系统和自省机制,理论上也能实现“端到端类型安全”。但方案复杂度摆在那里:Schema 定义、resolver 拆分、查询缓存、客户端缓存,一套折腾下来,对一个几篇文章的项目来说明显过重。REST 则需要在类型维护上投入额外成本。

我的结论很朴素:t3code 这类项目,前后端都在同一个 Next.js 应用里,没有多端消费需求,tRPC 是性价比最高的选择。等到项目复杂到需要拆服务,或者有第三方接入,再考虑在边界处引入 REST 网关也不迟。

3. 从零初始化 t3code:创建项目到第一次数据库迁移

初始化和配置阶段往往是被忽视的部分,实际却是新手最容易卡住的地方。t3code 从空目录到能跑通一次数据库查询,大概花了半小时,中间包含了命令行交互、环境变量配置、迁移命令执行三个关键环节。

3.1 创建项目,选择需要的特性

创建 t3code 用的是官方脚手架 create-t3-app,命令我用的 pnpm:

pnpm dlx create-t3-app@latest t3code cd t3code pnpm install

不同版本的 create-t3-app 交互项会略有变化,我当时勾选的是 TypeScript、Next.js、Tailwind CSS、tRPC、Prisma、NextAuth、ESLint。如果没有特别需求,建议先保持全选,因为后期手动加 tRPC 或 Prisma 的配置成本更高。

这里有一个新手容易忽略的点:create-t3-app 会让用户选择包管理器。如果混用 npm 和 pnpm,会导致 node_modules 结构不一致、脚本异常。我建议全程固定一个包管理器,我是直接用 pnpm。

3.2 环境变量配置的先后顺序

脚手架生成后会有一个.env.example文件,第一步不是急着写代码,而是把它复制成.env,填上真正的配置。t3code 至少需要三个核心变量:

DATABASE_URL="postgresql://user:password@localhost:5432/t3code?schema=public" NEXTAUTH_SECRET="一个足够长的随机字符串" NEXTAUTH_URL="http://localhost:3000"

NEXTAUTH_SECRET 可以用openssl rand -base64 32生成。很多登录问题都是因为这个变量没配或者配错。开发阶段我用本地 PostgreSQL,生产切换成云数据库,环境变量本身不需要变,只要替换 DATABASE_URL 的值。

值得提醒的是:.env不应该提交到 git。如果不小心提交,等于把数据库连接串和密钥暴露出去。模板的.gitignore通常已经忽略,但我仍然建议在推送前检查一遍。

3.3 第一次连接数据库并建表

t3code 第一次建表用的命令是 Prisma 的迁移命令:

pnpm prisma migrate dev --name init

这个命令会读取 schema 文件,生成迁移 SQL 并同步到本地数据库。migrate dev适合开发阶段,它会自动重放迁移、生成 Prisma Client。还有另一个命令prisma db push,它不做迁移历史,只把 schema 同步到数据库。前者的好处是表结构变更可追溯,所以我从项目一开始就用了迁移方式,而不是 db push。

很多初学 Prisma 的人会疑惑“为什么我改了 model,重新跑项目还是没表?”原因就是没有执行迁移。Prisma 不会自动把 model 变化同步到数据库,必须执行migrate dev或db push。

3.4 清理模板代码的步骤

脚手架自带了一个展示页面,里面有很多示例代码。我的清理顺序是:

  1. 先保留根目录app/layout.tsx里的TRPCReactProvider,这个 Provider 一旦删掉,所有api调用都会出错。
  2. 删除首页模板里的示例组件和示例数据。
  3. 删除未使用的_components目录下的模板文件。
  4. 确认app/api/trpc/[trpc]/route.ts存在,这是 tRPC 的 HTTP 出口。

我踩过一个小坑:清理模板时把根 layout 里的 Provider 误删,结果首页一直显示加载中,控制台也没有报错,排查了一阵子才发现。所以清理时宁可少删,也不要瞎动全局结构。

4. 核心功能落地:数据库、路由、页面三层联动

接下来是最核心的部分:t3code 的文章功能从数据库到页面是怎么串起来的。这一部分我会给关键代码,但更重要的是讲清楚每一层为什么这么做。

4.1 设计文章表的几个关键决定

我设计的 Post 模型并不复杂,但每个字段都思考过用途:

model Post { id String @id @default(cuid()) title String slug String @unique excerpt String? content String published Boolean @default(true) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt authorId String? author User? @relation(fields: [authorId], references: [id], onDelete: SetNull) } model User { id String @id @default(cuid()) name String? email String? @unique emailVerified DateTime? image String? posts Post[] }

slug 字段设置了@unique,因为详情页 URL 用 slug 而不是自增 id,更短也更语义化。excerpt 设为可选,列表页需要摘要时不必全文检索。published 字段用于控制发布状态,避免把未完成的文章暴露给游客。author 关联 User,不仅是为了显示作者名,也为后续做权限控制留了余地。

为什么 content 没有直接存 Markdown?我之前试过,Markdown 渲染必须引入额外的解析器,而 t3code 的正文内容更接近短文章,用纯文本加段落拆分就能满足需求。这个取舍是为了保持依赖简单,后续真要支持 Markdown,再加库也不难。

4.2 用 tRPC 写第一个业务路由

定义好模型后,我在server/api/routers/post.ts里写了文章相关的所有查询和变更逻辑:

import { createTRPCRouter, protectedProcedure, publicProcedure } from "~/server/api/trpc"; import { z } from "zod"; const createPostInput = z.object({ title: z.string().min(1, "标题不能为空").max(80, "标题最多 80 字"), slug: z.string().regex( /^[a-z0-9]+(?:-[a-z0-9]+)*$/, "slug 只能包含小写字母、数字和短横线" ), excerpt: z.string().max(200).optional(), content: z.string().min(10, "正文至少 10 个字"), published: z.boolean().default(true), }); export const postRouter = createTRPCRouter({ listPublished: publicProcedure.query(async ({ ctx }) => { return ctx.db.post.findMany({ where: { published: true }, orderBy: { createdAt: "desc" }, include: { author: { select: { name: true, image: true } } }, }); }), getBySlug: publicProcedure .input(z.object({ slug: z.string() })) .query(async ({ ctx, input }) => { return ctx.db.post.findUnique({ where: { slug: input.slug }, include: { author: { select: { name: true, image: true } } }, }); }), create: protectedProcedure .input(createPostInput) .mutation(async ({ ctx, input }) => { return ctx.db.post.create({ data: { title: input.title, slug: input.slug, excerpt: input.excerpt, content: input.content, published: input.published, authorId: ctx.session.user.id, }, }); }), });

publicProcedure 表示公开访问,protectedProcedure 表示必须登录。在 create 路由里,ctx.session.user.id是从 NextAuth 会话里取出的用户 ID,直接作为文章作者。zod 的作用不是简单的字段校验,它还承载了“类型来源”的功能:前端传过来的参数会先被 zod 验证,再被 TypeScript 推导成精确的入参类型。

这里有一个细节:slug 正则限制了只允许小写字母、数字和短横线。原因是 URL 里出现中文或空格,虽然浏览器能处理,但复制分享、服务端解析时都容易出问题。宁可创建表单里加一个规则,也不要让后续的 URL 处理变得混乱。

4.3 前端页面如何“零 API 代码”拿到数据

服务端路由写完后,前端调用比预想中简单得多。首页文章列表的组件大概是这样的:

"use client"; import Link from "next/link"; import { api } from "~/utils/api"; export function PostList() { const { data, isLoading, error } = api.post.listPublished.useQuery(); if (isLoading) return <p>加载中...</p>; if (error) return <p>加载失败:{error.message}</p>; return ( <ul className="space-y-6"> {data.map((post) => ( <li key={post.id} className="border-b pb-6"> <h2 className="text-xl font-semibold">{post.title}</h2> {post.excerpt ? <p className="mt-2 text-gray-600">{post.excerpt}</p> : null} <Link href={`/post/${post.slug}`} className="text-blue-600 hover:underline"> 阅读全文 </Link> </li> ))} </ul> ); }

这里直接调用api.post.listPublished.useQuery(),没有手写 fetch、没有手动处理 JSON、没有定义 response interface。data的类型就是 postRouter.listPublished 的返回值类型。这就是 tRPC 的“魔法”所在:服务端路由和前端 hook 是同一份 codegen 生成的结果,类型天然同步。

要注意的是,这个组件必须标记为"use client",并且必须被根 layout 里的TRPCReactProvider包裹。否则 useQuery 会一直处于 loading 状态,或者直接报错。

4.4 登录后才能写文章:protectedProcedure 与表单

创建文章的入口是登录用户才能访问的写作页。页面内部调用了createmutation:

"use client"; import { api } from "~/utils/api"; export function PostForm() { const utils = api.useUtils(); const createPost = api.post.create.useMutation({ onSuccess: () => { utils.post.listPublished.invalidate(); }, }); function handleSubmit(event: React.FormEvent<HTMLFormElement>) { event.preventDefault(); const formData = new FormData(event.currentTarget); createPost.mutate({ title: String(formData.get("title")), slug: String(formData.get("slug")), excerpt: String(formData.get("excerpt") || ""), content: String(formData.get("content")), published: true, }); } return ( <form onSubmit={handleSubmit} className="space-y-4"> <input name="title" placeholder="标题" required /> <input name="slug" placeholder="链接名称" required /> <textarea name="content" placeholder="正文" required /> <button type="submit" disabled={createPost.isLoading}> {createPost.isLoading ? "发布中..." : "发布"} </button> {createPost.error ? ( <p className="text-red-600">{createPost.error.message}</p> ) : null} </form> ); }

mutation 成功后,我调用utils.post.listPublished.invalidate()让首页列表重新拉取,这比手动刷新页面干净得多。如果创建失败,错误会直接显示在表单下方,里面包含 zod 校验时定义的提示文案,比如“标题不能为空”“正文至少 10 个字”。

在实际使用时我发现,tRPC 的错误信息默认是英文的 “Unexpected token ...”,但 zod 的 message 会被保留。所以我把校验文案写得尽量贴近中文用户。如果你也希望前端的错误提示与校验规则完全一致,可以让 tRPC 的 error formatter 格式化成可读结构,t3code 里我直接用了默认行为,已经够用。

5. 部署上线与高频问题排查

本地能跑通只算完成一半,部署上线后才算真正完整。t3code 的部署流程不算复杂,但有几个坑几乎人人都会踩。这一部分我把标准流程和排查经验放一起。

5.1 生产数据库与构建命令的配合

生产环境我选择了云 PostgreSQL,连接串同样写在环境变量里。有一点必须强调:生产环境不要用本地开发数据库,也不要直接暴露 root 账号,尽量创建独立的业务账号并开启 SSL 连接。Prisma 连接串通常会带?sslmode=require,不同云数据库的参数格式有差异,以对应厂商文档为准。

构建阶段最容易出问题的是 Prisma Client 没有生成。我后来把构建脚本改成了这样:

{ "build": "prisma generate && next build" }

原因很简单:每次从 Git 拉取代码后,node_modules 可能不是最新状态,Prisma Client 不一定存在。在 next build 之前先prisma generate,可以保证数据库客户端代码和 schema 一致。

生产环境的表结构更新,我不建议在发布流程里跑prisma db push,更稳妥的做法是先执行迁移:

pnpm prisma migrate deploy

migrate deploy会把迁移文件按顺序应用到生产库,并记录迁移历史。相比 db push,它更接近“正式发布”的语义,出问题时也更容易回滚到某个历史版本。

5.2 部署到 Vercel 的标准流程

t3code 部署到 Vercel 的过程可以总结成下面几步:

  1. 把代码推送到 Git 远端仓库。
  2. 在 Vercel 导入仓库,框架预设会自动识别 Next.js。
  3. 在 Vercel 环境变量配置里填写 DATABASE_URL、NEXTAUTH_SECRET(或 AUTH_SECRET)。
  4. 构建命令确认包含prisma generate。
  5. 部署前先手动执行一次prisma migrate deploy,让生产库表结构就绪。
  6. 访问线上 URL,登录并发布一篇测试文章。

关于登录相关的秘密,不同 NextAuth 版本使用的变量名可能不同。我遇到的情况是:本地开发随便配都行,线上登录后刷新就掉登录态,大概率是登录密钥不一致或没配置导致签发鉴权失败。部署后第一件事就是测登录流程,别等到用户反馈才发现。

5.3 高频问题速查表

我把 t3code 开发到部署期间遇到的问题整理成了一张表,按症状、原因、解决方式三列列出:

症状常见原因解决方式
页面一直显示“加载中”根 layout 缺少 TRPCReactProvider检查 app/layout.tsx,补上 Provider 包裹
构建报 PrismaClient 找不到构建前没有 prisma generate把 build 脚本改为prisma generate && next build
登录后刷新丢失会话NEXTAUTH_SECRET 缺失或与生产不一致生成固定密钥并配置在环境变量中
tRPC 请求返回 500DATABASE_URL 不可达、表未迁移检查连接串、执行 migrate deploy
列表页没有数据published 值为 false确保发布接口传入 published 为 true
创建文章提示 slug 重复数据库中已有相同 slug 值捕获唯一约束错误并提供友好提示

表格里的每一条我都在 t3code 的实际操作中碰到过,不是拍脑袋总结的。尤其是“页面一直加载中”这条,第一次遇到时完全没有任何报错,只能一步一步排查 Provider,最后还是靠阅读 tRPC 文档才定位。

5.4 我在 t3code 里踩过的三个具体坑

第一个坑是 slug 重复导致 Prisma 直接抛出唯一约束错误。最初我以为加上校验就不会重复,但用户可能手动输入相同 slug。解决办法是在 mutation 里捕获 P2002 错误码,并返回“链接名称已存在”的提示。Prisma 不同错误码对应不同状态,P2002 就是唯一约束冲突,这个知识以后做其他项目也能复用。

第二个坑是云数据库连接串里的参数问题。本地连接串没有开启 SSL,但云端数据库要求 SSL 连接,导致部署后 tRPC 查询全部超时。排查过程比较痛苦,因为本地完全正常、线上必现。最终通过查看服务端日志发现是 SSL 握手失败,改成带?sslmode=require的连接串后解决。

第三个坑是 Tailwind 深浅色模式切换。模板里默认有暗色模式,但我的页面结构迁移时没有把darkMode: "class"的配置一起抓过来,导致切换主题时文章正文的字体颜色没有跟随变化。这个问题不算严重,但提醒我一件事:模板代码卸载时也得检查全局配置文件有没有被误删,尤其是 tailwind.config 和 app/globals.css。

6. 项目维护与后续扩展的现实想法

t3code 上线后并没有停止开发,维护阶段我陆续加了一些小功能和代码层面的优化,也在这个过程中形成了对这套技术栈更真实的判断。

6.1 下一步可以加的功能

如果继续往下做,我会优先考虑这几个方向:

  • 标签系统:给文章添加标签,列表页按标签筛选。
  • 全文搜索:用数据库的 ilike 或 Postgres 全文检索,不需要引入额外搜索引擎。
  • 定时发布:给 Post 增加 scheduledAt 字段,配合定时任务更新 published。
  • 内容缓存:文章详情页可以尝试 Next.js 的缓存策略,减少数据库压力。

这几个方向都不需要推翻现有结构,因为它们都建立在一个核心优势上:数据库字段一旦在 Prisma 里定义,相关类型和前端页面几乎能自动同步更新。这正是当初选择这套技术栈的回报。

6.2 一条代码维护小技巧

t3code 的 tRPC 路由文件随着功能增加会变长,我建议在 router 内部按业务子域拆分,而不是把所有方法塞进一个文件。每新增一种资源,新建一个 router 文件,然后在server/api/root.ts里合并:

import { createTRPCRouter } from "~/server/api/trpc"; import { postRouter } from "~/server/api/routers/post"; import { userRouter } from "~/server/api/routers/user"; export const appRouter = createTRPCRouter({ post: postRouter, user: userRouter, }); export type AppRouter = typeof appRouter;

类型导出AppRouter后,前端~/utils/api会自动根据这个类型生成 hooks。如果哪天新加类型或改字段,只要 AppRouter 类型不变,前端调用端不会感知到破坏性变化。这种“以类型为中心”的维护方式,比维护一堆手动同步的接口声明轻松太多。

6.3 我个人做完这个项目的真实体会

做完 t3code 之后,我对 T3 Stack 的适用边界有了更具体的认知。类型安全带来的收益,在小项目初期并不明显,甚至会觉得“为了类型安全多写了 schema 和路由定义”。但当项目进入功能迭代阶段,改一个字段名、加一个查询条件,IDE 能立刻指出所有受影响的文件,这种确定性给我省下了大量肉眼排查时间。

如果让我给正准备上手 t3code 这种项目的人一句建议,那就是:不要一开始就追求功能多,先把一条完整的链路跑通,再从数据库字段开始逐渐加东西。类型系统的价值要在“改代码”的时候最能体现,跑通只是第一步。

另外,环境变量和构建脚本这类“基础设施”问题,值得在看文档时多花一点时间。我在部署阶段浪费的时间,大部分都花在连接串参数和 secret 配置上。这些内容在项目模板里不会单独高亮,但却是决定线上能不能跑起来的真正关卡。

t3code 这个小项目让我重新相信一件事:个人开发者也值得拥有工业级的开发体验。只要选对工具链,一个人维护一个内容站,也可以做到前后端类型统一、部署可重复、迁移有历史。后面如果继续扩展功能,我会沿着这条链路往下走,而不是推翻重来。

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

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

立即咨询