从去年开始,我一直想做一个属于自己的技术内容站点。市面上的博客平台其实够用,但总觉得少了点什么——我想在文章之外塞一些小的在线工具、代码片段展示、甚至实验性的交互页面。试了一圈方案后发现,与其在多个平台之间拼凑,不如直接自己动手做一个站。于是就有了这个名为t3code的项目:一个以 T3 Stack 为主干搭建的全栈代码与内容分享站点,前端用 Next.js,接口层走 tRPC,数据库交给 Prisma 和 SQLite,样式统一用 Tailwind 管理。这篇文章不打算写成一份面面俱到的教程,重点放在我实际动手时踩过的坑和真正有用的决策过程上。如果你也在考虑用这套技术栈做自己的内容型项目,或者对"为什么这么组合"感兴趣,这应该是一份不错的参考。
1. 为什么是 t3code:选型背后的真实权衡
1.1 T3 Stack 到底解决了什么问题
T3 Stack 不是某种新框架,它是一套组合拳:Next.js 管页面和路由,tRPC 管前后端接口,Prisma 管数据库操作,Tailwind 管样式。你可能会有疑问——这不就是把几个热门工具凑在一起吗?有什么特别的?
关键在于"端到端类型安全"这六个字。在 t3code 里,我写一个后端查询函数,它的入参和返回类型会被自动同步到前端调用处。以前我写传统项目,后端定义好接口文档,前端复制类型定义,一旦某个人改了字段名,另一个人根本不知道,等到运行时才发现报错。用了 tRPC 之后,后端改了返回结构,前端代码在编译阶段就直接标红,这种"类型在前后端之间流动"的体验,一旦习惯了就回不去。
当然,选型不能只谈优点,也要看适配场景。T3 Stack 强在"服务端渲染 + 同构代码 + 数据库直连"这种模式,非常适合 t3code 这类内容型、工具型项目。但如果你要做的是独立后端服务给多个第三方客户端调用,或者页面只是纯静态展示,这一套就偏重了。
1.2 为什么不用"Next.js + REST API"的组合
我在最初设计 t3code 的时候,第一反应其实是自己手写 REST API:在pages/api下建一堆路由,每个路由负责一个模型操作。听着挺常规,但真正接业务的时候问题就冒出来了。比如文章列表要支持分页、标签过滤和排序,REST 的做法是定义GET /api/posts然后传 query 参数,前端要根据接口文档手动写fetch和类型声明。两个接口还好说,等工具、文章、评论、用户偏好这些实体多起来,接口层的维护成本会显著增长。
tRPC 的做法不太一样。我把每个查询和变更都定义成一个个procedure,每个 procedure 天然就是一个"带类型的函数"。前端调用它就像调用本地函数一样,入参提示、返回结构全部自动生成。我不用再维护接口文档,也不用手动写类型定义文件,改动后端逻辑后所有依赖这个接口的页面会在瞬间被 TypeScript 编译器标出来。对于 t3code 这种一个人维护全部代码的站点来说,这套机制省下了大量心智负担。
一个很直观的体验:我在给文章详情页写阅读量统计时,后端在
getPostById的返回对象里增加了一个views字段。刚保存完代码,前端PostDetail组件就收到了类型提示,顺手就能在页面上渲染,零沟通成本。
2. 从 CLI 到跑通首屏:初始化与项目结构的门道
2.1 交互式初始化的那五分钟
创建一个 t3code 项目,官方推荐直接走create-t3-app脚手架。我的实际操作是这样的:
npm create t3-app@latest t3code命令执行后会进入交互式选择界面,需要勾选依赖模块。我的选择是:Next.js、TypeScript、tRPC、Prisma、Tailwind CSS、NextAuth.js。如果你只是做纯展示页面,可以去掉 tRPC 和 Prisma 相关项,但 t3code 需要数据库和登录能力,所以全选。
有一个小细节:脚手架目前默认生成的是 Next.js App Router 结构,和旧版 Pages Router 写法上差别很大。如果你之前习惯pages/api那套,刚切到 App Router 会有点不习惯,但适应之后会发现服务端组件的写法更干净,很多数据获取直接在服务端完成就行,不需要useEffect去拉数据。
2.2 目录结构里的设计暗示
脚手架生成的目录结构是很有信息量的,我建议你别急着写业务代码,先认真读一遍。
prisma/schema.prisma src/pages/api/trpc/[trpc].ts src/server/api/root.ts src/server/api/routers/* src/server/db.ts src/trpc/server.ts src/trpc/react.tsx粗略解释一下:
src/server/api/root.ts是所有 tRPC 路由器的汇总入口,叫appRouter。src/server/api/routers目录放具体业务路由,比如你定义了postRouter、toolRouter、commentRouter,就在这里维护。src/server/db.ts是 PrismaClient 实例的全局单例。src/trpc/server.ts和src/trpc/react.tsx分别是服务端和客户端的 tRPC 调用封装。
理解了这层结构之后,你写业务代码的路径就很清晰了:改prisma/schema.prisma定义数据模型,跑prisma migrate,在routers目录里写接口逻辑,在前端组件里引入封装好的trpcReact或trpcServer直接调用。层级关系明确,不绕弯。
2.3 启动过程中最常见的报错
第一次跑npm run dev,大概率会遇到一个数据库错误,因为 SQLite 数据库文件还没创建。脚手架不会自动帮你跑迁移,需要手动执行:
npx prisma migrate dev npx prisma db seedmigrate dev会根据schema.prisma生成迁移文件,同时创建数据库文件。db seed是往库里塞初始数据,比如默认的一条欢迎文章。如果没有种子数据,也可以写一个小脚本生成几篇测试文章,方便开发时调试列表和详情页。
3. tRPC 的全栈类型安全:它凭什么串起前后端
3.1 一次搞定 procedure、router 和 context
刚开始看 tRPC 的代码你可能觉得概念很多:router、procedure、context、middleware……其实用生活化的方式理解会特别简单。
想象你去一个餐厅吃饭。router就像餐厅的菜单,上面列了各种菜品;procedure是菜单上的具体菜名,比如"宫保鸡丁",每道菜都有固定的做法——这对应query和mutation;context则是厨房的原料仓库和后厨通行证,每个厨师做菜前都能去仓库拿原料,但能不能拿到某样特殊调料,要看有没有对应的权限。
回到代码里,我做的第一个业务是文章列表查询。最简化的流程就是三步:
// src/server/api/routers/post.ts import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "../trpc"; export const postRouter = createTRPCRouter({ list: publicProcedure .input(z.object({ page: z.number().min(1).int() }).optional()) .query(async ({ ctx }) => { return ctx.db.post.findMany({ orderBy: { createdAt: "desc" }, }); }), });zod负责输入校验,ctx.db是上一节提到的 PrismaClient 单例,直接操作数据库。这个list方法定义完之后,前端调用时就像调用本地 async 函数一样,类型完备到甚至不太需要读接口文档。
3.2 客户端调用的正确姿势
在客户端组件里,t3code 的做法是在根节点包一个TRPCProvider,然后就可以用api这个全局对象了。脚手架里通常会在src/trpc/react.tsx里导出api,里面已经绑定好了 React Hooks。
实际调用长这样:
const { data: posts, isLoading } = api.post.list.useQuery({ page: 1 });就这一行,posts的类型会自动匹配后端返回的findMany结果。数据刷新用useUtils换取缓存操作能力:
const utils = api.useUtils(); utils.post.list.invalidate();这个invalidate会自动让当前页面的相关查询重新执行。为了避免误刷新所有文章数据,你还可以传过滤参数,tRPC 会按输入的精确匹配去刷新对应的缓存条目,做得比一般封装细得多。
3.3 到底哪些情况不适合 tRPC
我一开始差点把 t3code 的所有第三方 API 请求也做成 tRPC procedure,后来很快就发现问题了:往外部服务发送的请求需要单独管理超时和重试,塞进 tRPC 里会把前后端直接耦合在一起。第三方接口返回结构不稳定,你没法控制它们做到"类型安全",强行套类型等于自己给自己埋坑。
我的建议是:tRPC 只用在"自己数据库业务 + 自己的业务逻辑"这一层。对接外部服务时,数据在前端或其他专门的服务层处理,不要在 tRPC 里做中转,否则项目后期维护会很难受。
4. Prisma 数据建模与迁移实操:不是简单的 CRUD
4.1 schema 设计上的几个取舍
t3code 的核心实体是Post、Tool、Tag、Comment和User。我在建模时做了两个重要决定。第一个是标签与文章的多对多关系,用 PostgreSQL 很方便,但 SQLite 也没问题,Prisma 会生成一张中间表。第二个是比较特殊的设计:文章内容用 Markdown 存储,而不是富文本content字段。原因是 Markdown 能直接处理代码块高亮,还能为之后的静态导出做准备。
schema 的一个关键片段:
model Post { id String @id @default(cuid()) title String slug String @unique content String published Boolean @default(false) tags Tag[] author User? @relation(fields: [authorId], references: [id]) authorId String? views Int @default(0) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Tag { id String @id @default(cuid()) name String @unique posts Post[] }比较值得注意的有两点:一是slug加了@unique约束,这样就能直接通过 slug 访问文章详情,URL 干净了不少;二是author是可选关系,因为早期允许匿名发文,后来加了登录再做强制绑定。如果你一开始就想做强登录体系,建议直接把authorId设为必填,省得后面再写兼容逻辑。
4.2 迁移中的几个常见坑
开发阶段跑npx prisma migrate dev一般很顺利,但我记一次线上构建环境遇到的问题。生产环境跑构建时,需要先执行npx prisma migrate deploy,这里和本地的migrate dev不一样:本地会顺便重新生成 Prisma Client,而生产环境构建的时候如果没有显式生成 client,构建产物会引用不到最新模型。
解决方案很简单,在部署脚本里串联执行:
npx prisma generate && npx prisma migrate deploy && npm run buildgenerate的作用是生成匹配当前 schema 的客户端代码,而且版本必须和 schema 保持同一时刻,否则极容易出现"本地明明能用,生产环境却报找不到字段"的情况。
还有一个点,SQLite 在并发写多的时候容易出问题。t3code 的目标场景是文章浏览、小工具,并发量不高,所以 SQLite 完全够用。如果你预计会有更高的并发访问,建议在部署阶段直接切到 PostgreSQL,Prisma 两个都能兼容,成本主要在编写业务代码时做好字段类型区分。
4.3 查询优化的基本盘
Prisma 默认不返回关联数据,你要刻意用include或select来声明。举个例子,文章列表页不需要正文全文,只展示标题、摘要和更新时间:
post.findMany({ select: { id: true, title: true, slug: true, views: true, createdAt: true, tags: { select: { name: true } }, }, orderBy: { createdAt: "desc" }, });这样写 SQLite 实际执行的查询不会把正文捞出来,响应更快,网络传输也更小。另一个细节是@updatedAt字段由 Prisma 自动维护,不需要你自己在业务代码里更新,否则很容易重复赋值,造成数据覆盖问题。
5. NextAuth 认证接入:登录与权限控制的细节
5.1 Provider 配置里容易被忽略的那行代码
我在 t3code 里接入了 GitHub 登录。第一步是安装依赖,接着写[...nextauth].ts配置。脚手架生成的authOptions里有一个 Placeholder 的secret,如果你不改成真实密钥,本地开发可能没问题,但在生产环境登录功能就会频繁报错。
生成密钥的方式很简单:
openssl rand -base64 32然后写入.env文件:
NEXTAUTH_SECRET=填入生成的密钥 GITHUB_ID=你的GitHub App Client ID GITHUB_SECRET=你的GitHub App Client Secret另外一个容易漏掉的是 Authorized redirect URIs。你要在 GitHub App 配置里填上完整的回调地址,例如https://t3code.example.com/api/auth/callback/github。如果漏填或填错,OAuth 流程会卡在确认页面或直接跳 500。
5.2 保护页面的正确思路
t3code 里有几个页面是登录后可见的,比如"个人工具管理"。保护方式我建议不用 middleware 做全局拦截——那样很容易因为底部一行配置失误导致所有页面都跳登录页,而且排查起来还费劲。更好的做法是直接在服务端组件里判断会话:
import { getServerSession } from "next-auth"; export default async function DashboardPage() { const session = await getServerSession(); if (!session) return <p>请先登录之后再访问</p>; return <Dashboard session={session} />; }把权限控制放在离数据近的地方,比全局中间件更可控。另外,tRPC 里也可以用中间件做接口层防护,比如protectedProcedure就是一个加了isAuthed中间件的procedure。这样即便有人绕过页面守卫直接调用接口,后端依然能挡住未授权请求,双保险还是比较重要的。
我自己的一个教训是:最初只做了前端守卫,没有包裹 tRPC 中间件,结果通过
curl直接调用接口还是能拿到数据。后来花了大半天把所有涉及用户数据的 procedure 全部包了一层protectedProcedure,才算真正堵住了接口权限漏洞。
5.3 数据库适配器与 User 模型打通
用了 NextAuth 之后,用户信息要落到本地数据库,就需要在 Prisma schema 里配置关系字段。脚手架生成的User模型里包含accounts、sessions和authenticator等关系,运行npx prisma migrate dev会自动创建对应表。刚开始运行的时候会遇到一个提示:没有配置数据库适配器,NextAuth 默认使用 JWT 策略。t3code 的目标是看得到"谁写了文章",所以我选用了数据库会话策略,并把PrismaAdapter挂进了authOptions:
import { PrismaAdapter } from "@next-auth/prisma-adapter"; import { db } from "~/server/db"; export const authOptions = { adapter: PrismaAdapter(db), session: { strategy: "database" }, };这样一个配置改动,登录的用户信息就会自动存到本地表里,同时session.user.id也能在服务端组件中直接读到。写入文章时,就能把authorId正确关联到当前登录用户。
6. Tailwind 样式组织:从默认模板到站点风格
6.1 用主题配置统一设计变量
T3 Stack 默认内置 Tailwind,但直接用默认色值去做内容型站点会显得比较"样板间"。我在 tailwind 配置文件里扩展了一套自定义色板:
import type { Config } from "tailwindcss"; const config: Config = { content: ["./src/**/*.{ts,tsx,mdx}"], theme: { extend: { colors: { surface: "#faf9f7", ink: "#1f1e1d", accent: "#c2410c", accentHover: "#9a3412", }, fontFamily: { mono: ["JetBrains Mono", "Fira Code", "monospace"], }, }, }, plugins: [require("@tailwindcss/typography")], };有了这组变量,整个站点的按钮、卡片、链接和代码块风格就能保持一致,改一处全局生效。如果你直接套 Bootstrap 那种组件库,表面上能快速产出页面,但最后一定会为了改回自己想要的风格来回覆盖 CSS,反而更费时间。
6.2 代码块高亮与文章排版
t3code 的文章内容以 Markdown 为主,代码块则是技术站点的灵魂。我最终选了react-markdown配合rehype-highlight做渲染,同时使用@tailwindcss/typography插件来保证文章列表和正文的排版质量。默认的prose类非常方便,给文章内容容器加上prose prose-invert就能获得一组贴心的排版样式。
<article className="prose prose-lg max-w-none"> <Markdown rehypePlugins={[rehypeHighlight]}>{post.content}</Markdown> </article>这里有个小坑:如果你用的代码高亮插件按语言自动加载样式,别忘了在全局 CSS 里引入对应主题,否则代码块只会显示五彩斑斓的文本标签,没有背景色和语法高亮。我在第一次上线时踩过这个坑,代码块看起来跟纯文本似的,排查半天才发现是样式文件没引入。
7. 部署到 Vercel:构建与环境的那些隐藏问题
7.1 构建时最容易挂的三个环节
打包部署看起来只是推一次代码,但实际操作中我遇到过不少隐藏赛道。三件事最容易把构建过程搞挂:
第一,环境变量缺失。Next.js 构建服务端组件时会直接读取环境变量,如果.env.production没有配置齐全,构建产物会引用到空字符串或 undefined,很容易导致页面报错。
第二,Prisma Client 未生成。前面提到过,构建流程里没有prisma generate就会报找不到@prisma/client里的类型,尤其是新建了模型但没重新生成时。
第三,NextAuth Secret 没设置。这个听起来离构建很远,实际上 NextAuth 在服务端渲染时会读取配置,如果 secret 为空,很多依赖会话的页面会在构建期抛出异常。
我的 Vercel 构建命令配置如下,屡试不爽:
npx prisma generate && npx prisma migrate deploy && npm run build7.2 SQLite 在生产环境的方案选择
这里我必须展开说。本地开发用 SQLite 文件很方便,但当你部署到 Vercel 的服务端环境下,SQLite 文件是存在 Lambda 临时磁盘上的,每次冷启动可能被清掉,完全不适合做持久化。对 t3code 这个小站来说,低成本、靠谱的方案有两种:
一是把数据库迁到 PostgreSQL(比如用 Neon 或 Supabase 提供的免费档),把.env里的DATABASE_URL指过去。这也是 create-t3-app 官方推荐路线。
二是如果坚持用 SQLite,就把数据库文件存到持久化存储卷里,一些专业部署平台支持挂载持久盘,但 Vercel Serverless 环境天然不适合。
我自己最终选了 Postgres。切库的步骤很简单:改schema.prisma里的 datasource provider,跑一次全量迁移,再复制数据。之后DATABASE_URL里的连接串换成云端地址即可。记住一个铁律:任何 Serverless 平台都要把数据库外置,这是生产环境的底线。
7.3 热更新和生产环境的差异
最后一个比较常见但反直觉的坑:本地npm run dev一切正常,热更新响应飞快;到了生产环境却偶尔出现接口第一次访问特别慢的情况。这通常是 Lambda 冷启动导致的,界面层往往表现为首屏白屏几秒。缓解方法也比较直接:在 Vercel 项目配置里打开"Node.js 版本"并用 18 或 20 去跑,同时尽量缩小打包体积,tRPC 和 Prisma 本身相当轻量,正常情况下冷启动在可接受范围内。如果项目变大,可以考虑把改动频繁的路由拆成独立函数,或者改用更有状态能力的环境。
8. 一次线上文章的"消失":完整排查链路与最终修复
8.1 突发情况与初步排查
就在我以为 t3code 已经稳定运行的时候,一个用户反馈说"发表的文章在列表里找不到"。当时脑袋里闪过几个原因:数据库没写入成功?列表查询没查到?还是前端缓存把旧数据返回了?
我第一步登录后台直接查看数据库,结果文章记录确实存在,published字段也是true。那问题大概率出现在列表查询或前端缓存层。接着打开浏览器开发者工具的 Network 面板,发现访问列表接口时返回的数据里根本没有这条新文章,于是锁定问题在列表 API 而非前端缓存。
8.2 根因:闭包里的旧版本过滤器
定位到 API 之后,我开始检查post.list这个 procedure 的代码。发现列表查询确实带了published: true过滤,但还带了另一组标签过滤条件,而标签筛选是从一个"标签名到 ID 的映射表"里取值的。新文章关联的标签是用户刚刚创建的,这个标签存在于数据库里,但映射表没有同步刷新。闭包里保存的还是上一次构建时生成的旧映射,所以就查不到新文章。
这里其实有两种解法:一是在每次服务启动时都重建映射表,但这意味着任何标签修改都要触发全量缓存失效;二是不要依赖内存映射,直接查询数据库关联表。我最后选了第二种方案,把标签过滤改成对tags表做一次关联查询。
const tagIds = await ctx.db.tag.findMany({ where: { name: { in: selectedTagNames } }, select: { id: true }, });虽然多查了一次数据库,但彻底消除了闭包缓存和数据库数据不一致的问题,收益远大于那几毫秒的损耗。
8.3 从这批问题里总结出的经验
这次排查让我意识到,知识型站点虽小,但前后端数据一致性和状态同步是绕不开的话题。列表、详情、更新操作之间的缓存失效策略必须想清楚。我的最终方案是所有写操作结束前调用一次utils.post.list.invalidate(),写操作的位置放在 tRPC mutation 内部而不是页面里,这样任何页面触发写操作都会自动清理缓存,不用在页面组件上到处撒代码。
前端 React Query 的staleTime我设置成 30 秒,意味着同一页面的二次访问不会频繁向后端打请求,用户体验更顺畅,但数据变更后又能及时刷新出来。这组配置经历了几轮调试才稳定下来,核心原则是:查询多设staleTime,变更后主动invalidate,两者搭配才能兼顾性能与一致性。
9. 内容之外:小工具页面与交互设计的扩展思路
9.1 把站点从"博客"升级为"工具箱"
t3code 最初的定位是博客,但博客这种形式天然局限在"阅读"场景。我想在文章旁边放点更好玩的东西,于是规划了一个"工具"分区。现在站上有 JSON 格式化、Base64 编解码、时间戳转换这三个小工具。每个工具本质是一个独立的客户端组件,放在src/app/tools/[tool]目录下,不需要后端接口。
这些工具虽然简单,但很能说明一个问题:Next.js 的 App Router 很适合做这种混合型站点。博客文章走服务端渲染,便于被搜索引擎收录;小工具走客户端交互,完全免请求,首屏就能玩。两者在同一站点里互不干扰,部署成本也没有明显增加。
9.2 搜索功能的简单实现
博客站一旦文章多了,搜索就是刚需。t3code 没有上重型搜索服务,我用 Prisma 的简单查询做了一个基础搜索:文章按标题和 Markdown 正文匹配关键词,然后分页返回:
post.findMany({ where: { published: true, OR: [ { title: { contains: keyword } }, { content: { contains: keyword } }, ], }, select, });这套方案在小规模数据下够用。单机部署、数据量几百篇,数据库本身做LIKE查询的速度能接受。如果以后文章到几千篇,考虑切 Postgres 的全文检索或引入独立搜索引擎不迟,现阶段不想为了一个搜索框把架构搞复杂。
9.3 暗色模式与主题切换
内容型站点基本绕不开暗色模式,t3code 自然也不例外。最初我想用比较流行的next-themes,但发现它和 Tailwind 的darkMode: "class"配置配合得也很好。实现步骤很快:安装next-themes,在根布局加ThemeProvider,然后在tailwind.config.ts开启 class 模式,页面元素用dark:前缀控制风格。
之前有人跟我说暗色模式就是两个颜色的问题,实际上不止。代码块在暗色模式下要做单独适配,图片透明度需要考虑,甚至表格的边框也要重新调一遍。想要整体观感统一,必须在一开始就把主题色调设计好,而不是事后一块块去补。
10. 给我的项目做一次复盘:值得坚持的做法与后续计划
做t3code的过程里,有一些做法如果重来我还会坚持,也有一些可以更好。先说值得坚持的:
第一,选型克制。虽然最终上手的是 T3 Stack 全家桶,但每一步都是从"眼前的实际需求"出发的。文章想被搜索引擎收录,就选 Next.js 服务端渲染;写接口懒得维护文档,就选 tRPC;数据建模不想手写 SQL,就用 Prisma。这个技术栈带来的体验提升是实打实的。
第二,遇到问题按链路排查。生产环境出的问题,先用日志和数据判断,再去看代码和缓存,别一上来就重跑整条链路或者盲改代码。那次文章消失的问题如果只盯着前端缓存修,大概率修错方向。
第三,把权限和数据库设计做扎实。虽然是小站,但用户体系和接口鉴权按正规思路来,后面要加功能时不会束手束脚。
再说说后续计划。短期想把工具区做成可插拔的模块,每次加一个工具不用动站点主体,直接在目录里注册路由就行。长期想给文章加上短链和阅读统计,让数据驱动选文方向。代码目前就放在 GitHub 仓库里,环境通过.env管理,推到 Vercel 自动部署,整体维护成本已经降到很低了。
做这个项目的整体体会就是:技术栈选对了,开发速度会快很多;很多看起来高深的问题其实就是一层缓存或者一条环境变量的差异。我不是什么架构大师,只是在每天写代码的过程中,把真实遇到的问题一个个解决掉,然后把项目和踩坑过程一起留在这里。希望你用这套技术栈做自己的站点时,能比我少踩几个坑,遇到问题也能一眼看清方向。