最近我一直在折腾一个叫 t3code 的个人全栈项目,说白了就是用 T3 Stack 从零搭一套带数据库、登录、CRUD 的完整应用。折腾完最大的感受是:以前写接口要在后端定义一套、前端再手工造一遍类型,被 tRPC 的端到端类型安全打破之后,整个开发节奏完全不一样了。这篇文章就从 t3code 的选型讲起,把初始化、tRPC 接口、Prisma 数据层、部署排查这些环节摊开来说,每一步都可以直接抄作业。
如果你正准备做一个中小型全栈项目,或者被前后端类型不一致、接口文档过期这类事烦过,这篇应该会对你有用。我尽量把“为什么这么选”“踩过哪些坑”也讲清楚,而不是只丢一堆命令和代码。
1. t3code 到底是什么:T3 Stack 全栈方案的价值拆解
1.1 T3 Stack 到底由哪些技术组成
T3 Stack 不是一个框架,而是一套组合拳。它把几个“T”开头的技术绑在一起,分别是 TypeScript、Tailwind CSS、tRPC,再加上 Next.js、Prisma、NextAuth 这些配套。t3code 这个项目名,其实就是这套组合在我这里的实践代号。
先列一张表,把这几个成员的角色说清楚:
| 技术 | 在 t3code 里扮演的角色 | 一句话概括 |
|---|---|---|
| Next.js | 应用框架 | 负责页面渲染、路由、API 处理 |
| TypeScript | 语言底座 | 让所有代码都有静态类型 |
| Tailwind CSS | 样式方案 | 用原子类快速搭 UI |
| tRPC | API 层 | 前端像调用本地函数一样调用后端 |
| Prisma | 数据层 | 类型安全的数据库 ORM |
| NextAuth | 鉴权体系 | 登录、Session、OAuth 一套带走 |
单独拿出来,每一项你可能都听过。真正值钱的是组合后的效果:从数据库字段到 tRPC 接口,再到前端组件里的数据对象,类型信息全程贯通。通俗点说,就像餐厅菜单和后厨用的是同一份食材清单,客人点单时报错,后厨还没动手就已经知道菜做不了。
1.2 为什么选择这套组合:和传统全栈方案的对比
传统方案里,最常见的是 React/Vue 前端 + Express/NestJS 后端 + REST API。这套路本身没问题,但维护成本会随着项目变大而快速上升。后端加一个字段,前端要手动补类型;后端改一个接口路径,前端要全局搜索替换;接口文档稍微滞后,前后端就各写各的,联调时全靠浏览器 Network 面板猜。
t3code 用 tRPC 把中间层直接省略了。后端定义一个 router,前端立刻获得对应的 hooks,参数类型、返回值类型全部自动推导。我做过一个对比,同样实现“文章列表 + 创建文章”两个接口,传统方案要写后端路由、DTO 定义、前端 API 封装、类型声明四份代码,T3 方案只需要后端写一份业务逻辑。
| 对比维度 | 传统 REST + 手写类型 | t3code(T3 Stack) |
|---|---|---|
| 接口定义 | 路径 + DTO 靠文档维护 | router 定义后类型自动流入前端 |
| 参数校验 | Joi/class-validator 另写一套 | Zod 一处声明,前后端共用 |
| 客户端调用 | 手写 fetch + 类型断言 | useQuery/useMutation 自动推导 |
| 类型一致性 | 容易漂移,靠联调发现 | 编译期强制约束,改错立刻爆红 |
| 接口文档 | 需要额外维护 | 对中小项目基本不需要 |
当然,这不是说 REST 没用了。t3code 这种方案适合内部系统、中后台、MVP,因为接口只给自己人用,类型安全的收益最大。如果将来要把 API 开放给第三方,或者需要和移动端、外部服务对接,REST/GraphQL 依然是更通用的选择。我自己的原则是:内部端到端用 tRPC,对外暴露用 REST,两者可以共存,不冲突。
1.3 哪些项目最适合拿这套方案开刀
先说实话,t3code 不是银弹。我总结了几类非常合适和不太合适的场景。
合适的场景:
- 团队内部工具,比如运营后台、数据管理平台、内部工单系统
- 需要快速验证业务的 MVP 项目,一个月内要上线
- CRUD 密集的 CMS、博客、笔记类产品
- 个人全栈项目,一个人同时写前后端,最吃这套类型红利
- 想系统学习 TypeScript 全栈的开发者,这套组合可以把类型思维一次性打通
不太合适的场景:
- 大型微服务架构,服务之间边界复杂,tRPC 的优势会被冲淡
- 团队里的前端或后端有一方不用 TypeScript,类型贯通无从谈起
- 需要把 API 开放给第三方生态,公开接口还是老老实实用 REST
- 历史项目里已经有大量 REST 接口,硬迁 T3 成本很高,不如渐进式改造
我见过一些团队为了赶时髦强行上 T3,结果前端不会 tRPC、后端不习惯 Prisma,反而比原来更慢。工具没有好坏,匹配场景才重要。t3code 的价值在“全栈一体”的地方才能放大。
2. 环境准备与项目初始化:从零跑起一个 t3code 工程
2.1 初始化前需要准备的本地环境
在跑 create-t3-app 之前,先把本地环境确认一遍。我踩过最蠢的坑就是 Node 版本太老,脚手架装到一半报语法错误,排查半天才发现是 nvm 切错了版本。
最低要求如下:
- Node.js 18 及以上,推荐 20 LTS
- 包管理器用 npm 或 pnpm,我习惯用 pnpm,安装速度快,磁盘占用也小
- Git,用来做初始化和版本管理
- 数据库:开发阶段用 SQLite 最省事,文件即数据库;如果目标环境是 PostgreSQL,也可以提前连远程库
检查命令很简单:
node -v pnpm -v git --version如果 Node 版本不够,用 nvm 切换一下就好。不要在这种基础环境上偷懒,后面所有问题排查都会因为你有一个“确定干净的起点”而简单很多。
2.2 使用 create-t3-app 生成工程
create-t3-app 是 T3 Stack 的官方脚手架,运行一条命令就能把项目骨架拉起来。
npm create t3-app@latest t3code-app如果你用 pnpm:
pnpm create t3-app@latest t3code-app执行后会有几个交互选择项,我逐个说下该怎么选:
- TypeScript:必选,这是整套方案的地基
- ESLint / Prettier:都选上,团队协作时能省掉大量格式口水仗
- App Router 还是 Pages Router:新项目直接 App Router,这是 Next.js 当前主推的方向
- tRPC、Prisma、NextAuth:按需选择。我建议第一次做就全选,t3code 的完整形态就靠它们撑起来
- Tailwind:选上,哪怕你不喜欢原子类,先跑通再谈取舍
生成完进入目录,安装依赖:
cd t3code-app pnpm install这里有个小提醒:create-t3-app 生成的项目会在启动时做环境变量校验,所以直接pnpm dev大概率会报错。你得先复制.env.example为.env,把数据库连接等关键配置填好。具体怎么填,看下一节。
2.3 目录结构与关键文件解读
生成后的目录结构大致如下,不同版本可能略有出入,但核心模块的位置是稳定的:
t3code-app/ ├─ app/ │ ├─ api/trpc/[trpc]/route.ts │ └─ ...页面组件 ├─ prisma/ │ ├─ schema.prisma │ └─ seed.ts ├─ server/ │ ├─ api/ │ │ ├─ routers/ │ │ ├─ root.ts │ │ └─ trpc.ts │ └─ db.ts ├─ trpc/ │ ├─ client.ts │ ├─ react.tsx │ └─ server.ts ├─ env.mjs └─ package.json我挑几个重点文件解释一下:
server/api/trpc.ts是 tRPC 实例的创建处,里面会initTRPC,还会定义公共的 publicProcedure 和带鉴权的 protectedProcedure。所有中间件都在这里挂载。
server/api/root.ts是总路由聚合器。你写的每个业务子路由,比如 postRouter、userRouter,都在这里汇入 appRouter。前端类型AppRouter也是从它推导出来的。
app/api/trpc/[trpc]/route.ts是 Next.js 的 Route Handler,负责把 HTTP 请求转交给 tRPC 处理。如果你发现 tRPC 请求 404,八成是这个文件的路径或导出出了问题。
trpc/server.ts和trpc/client.ts分别对应服务端调用和客户端调用的入口。服务端调用可以让你在 Server Component 里直接取数据,不走额外的 HTTP 请求,这个后面细说。
prisma/schema.prisma是你的数据模型文件,改表结构就是改它。
2.4 环境变量配置细节
create-t3-app 默认会带一个env.mjs,它用@t3-oss/env-nextjs做启动时校验。也就是说,环境变量不对,服务根本起不来。这是好事,能在最开始就暴露问题,而不是等运行时才炸。
.env里至少要配这几项:
DATABASE_URL="file:./dev.db" NEXTAUTH_SECRET="随便生成一段长随机字符串" NEXTAUTH_URL="http://localhost:3000"注意,NextAuth 的版本不同,变量名可能不同。新版本可能用AUTH_SECRET和AUTH_URL,具体以生成的.env.example为准。我的经验是:永远先看模板默认给了什么,再往上填,不要凭记忆乱写。
另外,强烈建议把.env加入.gitignore,只提交.env.example。这样别人 clone 项目后,复制一份示例文件就能快速启动,同时不会把你的数据库连接串和密钥泄露出去。团队协作时,这个习惯能避免不少尴尬。
3. 核心实操:用 tRPC 把 API 层做薄做准
3.1 tRPC 的基本概念:Router、Procedure、Middleware
tRPC 的逻辑其实很好理解。我通常用餐厅来类比:
- Router 是菜单,告诉别人这家店有什么菜
- Procedure 是单个菜品,可以理解为接口端点,分 query 和 mutation 两种
- Middleware 是餐厅门口的安检,比如检查你是否有会员卡
query负责查数据,mutation负责改数据。这个区分和 React Query 的语义天然对应,前端调用时心智负担非常低。
先看 tRPC 实例的初始化:
// server/api/trpc.ts import { initTRPC, TRPCError } from "@trpc/server"; import type { Context } from "./context"; const t = initTRPC.context<Context>().create(); export const router = t.router; export const publicProcedure = t.procedure; const isAuthed = t.middleware(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { user: ctx.session.user } }); }); export const protectedProcedure = t.procedure.use(isAuthed);这里最关键的是中间件。普通公开接口用publicProcedure,需要登录的接口用protectedProcedure,调用时它会先检查 session,没有登录就直接抛错,省得在每个业务代码里重复写鉴权判断。
3.2 定义第一个业务 Router:一个帖子模块
有了基础实例,写业务接口就很快了。我在 t3code 里先写了最经典的 post 模块,包含列表和创建两个接口。
// server/api/routers/post.ts import { z } from "zod"; import { router, publicProcedure, protectedProcedure } from "../trpc"; export const postRouter = router({ listRecent: publicProcedure.query(async ({ ctx }) => { return ctx.db.post.findMany({ take: 10, orderBy: { createdAt: "desc" }, }); }), create: protectedProcedure .input( z.object({ title: z.string().min(1).max(80), content: z.string().min(1), }) ) .mutation(async ({ ctx, input }) => { return ctx.db.post.create({ data: { title: input.title, content: input.content, authorId: ctx.user.id, }, }); }), });我在项目里习惯把 Zod schema 直接写在路由文件里,因为这里的输入校验只属于这个接口,没必要拆到别处。Zod 的好处是前后端共用同一份校验规则,前端在调用create时,如果少传一个字段或者类型不对,编辑器立刻标红,根本不需要等后端返回 400。
然后把这组路由挂到总路由上:
// server/api/root.ts import { postRouter } from "./routers/post"; import { router } from "./trpc"; export const appRouter = router({ post: postRouter, }); export type AppRouter = typeof appRouter;前端用到的所有类型,都是从AppRouter自动推导出来的。
3.3 前端调用:useQuery 与 useMutation 的爽点
create-t3-app 会生成一个utils/api.ts,它做的事情就是创建一个带类型的 tRPC React 绑定:
// utils/api.ts import { createTRPCReact } from "@trpc/react-query"; import type { AppRouter } from "~/server/api/root"; export const api = createTRPCReact<AppRouter>();然后页面里就可以直接这样写:
function PostList() { const { data: posts, isLoading } = api.post.listRecent.useQuery(); const createPost = api.post.create.useMutation({ onSuccess: () => { // 这里触发列表重新拉取 }, }); if (isLoading) return <div>加载中...</div>; return ( <ul> {posts?.map((post) => ( <li key={post.id}>{post.title}</li> ))} </ul> ); }我第一次用的时候,最大的震撼是调用createPost.mutate时,编辑器会自动弹出参数提示:title 是 string,content 是 string,多传一个字段直接编译报错。这在以前调 REST 接口时是根本不可能的,少参数、错类型只能等运行时再看。
还有一个小技巧:mutation 的onSuccess里,除了 refetch,还可以用api.post.listRecent.invalidate()让 React Query 失效并自动重新拉取。这个比手动 refetch 更优雅,也是官方推荐的做法。
3.4 权限控制:用中间件统一处理登录态
权限这块,我在第 3.1 节已经写了isAuthed中间件。它的作用是把 session 里的用户信息注入到ctx.user,之后的业务代码可以直接用。
中间件的好处特别明显:假设你有 6 个接口需要登录,传统写法每个接口都要写一遍“检查 token - 解析用户 - 处理异常”,而 t3code 里只需要在定义时换一个protectedProcedure。以后如果想调整鉴权逻辑,比如加个黑名单判断,只改中间件一处就能全局生效。
我在 t3code 里还加了一个小需求:只有作者本人能改自己的文章。做法是在接口里先查一遍post.authorId和ctx.user.id是否匹配:
update: protectedProcedure .input(z.object({ id: z.string(), title: z.string().min(1) })) .mutation(async ({ ctx, input }) => { const post = await ctx.db.post.findUnique({ where: { id: input.id } }); if (!post) throw new TRPCError({ code: "NOT_FOUND" }); if (post.authorId !== ctx.user.id) { throw new TRPCError({ code: "FORBIDDEN" }); } return ctx.db.post.update({ where: { id: input.id }, data: { title: input.title }, }); }),这种业务级权限放到接口内部判断,比放在前端隐藏按钮可靠得多。前端隐藏只是一种体验优化,真正的安全边界永远要在服务端。
3.5 在 Server Component 里直接调用 tRPC
App Router 模式下,Next.js 支持在服务端组件里直接取数。tRPC 提供了一个 server caller,可以绕过 HTTP 请求直接调用 router。
// app/page.tsx import { api } from "~/trpc/server"; export default async function Home() { const posts = await api.post.listRecent(); return <PostList initData={posts} />; }这样做的价值在于:首屏不产生额外的网络往返。传统方案里,浏览器先加载 HTML,再发 Ajax 请求拿数据,中间少说几百毫秒;而 server caller 在服务端直接查数据库,把结果渲染进页面,用户打开页面就是完整内容。
这招适合对首屏速度敏感的场景。但也别滥用,服务端组件里直接查库意味着每次页面刷新都触发查询,如果页面是强交互型,反而用客户端 query 更灵活。我一般是:首屏数据用 server caller,交互后的数据用前端 useQuery。
4. 数据层:Prisma 建模、迁移与种子数据
4.1 Schema 建模:从业务出发定字段
t3code 的数据层用的是 Prisma。它的 schema 文件写起来很像在定义对象,字段、关系、索引都在一个文件里管理。我先定义一个 User 和 Post 的双表结构:
model User { id String @id @default(cuid()) name String? email String? @unique posts Post[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Post { id String @id @default(cuid()) title String @db.VarChar(80) content String @db.Text published Boolean @default(false) author User @relation(fields: [authorId], references: [id], onDelete: Cascade) authorId String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([authorId, createdAt]) }几点注意:
cuid()作为默认 ID 生成方式,适合分布式环境,不会像自增数字那样暴露业务量。如果你能确定项目规模不大,用@default(autoincrement())也可以,但我不太喜欢把业务 ID 交给数据库自增,所以一直用 cuid。
@db.VarChar(80)和@db.Text是在告诉数据库用具体的列类型。标题用变长字符串,正文用长文本,这种显式声明会让数据库层面的存储更合理。
复合索引@@index([authorId, createdAt])很有用。常见的文章列表查询就是“某个用户的所有文章按时间倒序”,这个索引能覆盖该查询条件,避免全表扫描。项目刚起步时数据量不大看不出来差距,等数据到了十万级,索引有没有效果是天壤之别。
4.2 迁移与 Seed:让数据库跟上代码
写完 schema,要执行迁移。开发环境用:
npx prisma migrate dev --name init这个命令会做三件事:对比 schema 变化、生成迁移 SQL、执行到本地数据库。如果本地用的是 SQLite,它还会自动建库文件,非常方便。
seed 我建议一定要做。没有测试数据,开发页面时总是空荡荡的,调试体验极差。在package.json里加上 seed 配置:
"prisma": { "seed": "tsx prisma/seed.ts" }然后写一个prisma/seed.ts:
import { PrismaClient } from "@prisma/client"; const prisma = new PrismaClient(); async function main() { const user = await prisma.user.upsert({ where: { email: "demo@example.com" }, update: {}, create: { email: "demo@example.com", name: "Demo User" }, }); await prisma.post.createMany({ data: Array.from({ length: 10 }).map((_, i) => ({ title: `第 ${i + 1} 篇文章`, content: "这里是种子数据内容", authorId: user.id, })), }); } main() .then(() => prisma.$disconnect()) .catch((e) => { console.error(e); process.exit(1); });执行npx prisma db seed,数据库里就有了 1 个用户和 10 篇文章。upsert是个好函数,能避免重复执行 seed 时产生重复数据。
重点提醒:生产环境不要用migrate dev。它会要求交互确认,还可能重置数据。生产环境应该用prisma migrate deploy,它只执行未应用的迁移文件,不会重置任何东西。这是新手最常犯的错误之一。
4.3 在 tRPC 中安全地查库
Prisma 和 tRPC 搭配起来,最舒服的是不需要写 DTO。tRPC 的 query 直接返回 Prisma 对象,前端拿到的是什么形状,类型已经定死了。但这不代表可以偷懒,我一直遵循几条原则:
第一,只查需要的字段。用select或include控制返回内容,而不是把整张表的数据都丢给前端。
list: publicProcedure.query(async ({ ctx }) => { return ctx.db.post.findMany({ select: { id: true, title: true, published: true, }, }); }),第二,找不到数据时明确抛错。
detail: publicProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const post = await ctx.db.post.findUnique({ where: { id: input.id } }); if (!post) throw new TRPCError({ code: "NOT_FOUND" }); return post; }),用TRPCError而不是直接返回null,好处是前端能拿到明确的错误码,后续做错误统一处理会更方便。
第三,关系字段名要起得让人看得懂。authorId和author这种命名,比userId和user更容易在业务代码里分辨。
5. 常见问题与排查技巧实录
5.1 改了表结构,前端类型没有更新
这个是我身边朋友问得最多的一个问题。明明是schema.prisma里加了字段,也跑了迁移,为什么前端代码还是不认识这个新字段?
原因基本只有一个:Prisma Client 没有重新生成。prisma migrate dev本身会触发 generate,但如果你只改了 schema 忘了跑迁移,或者在不同终端操作,就有可能出现这种问题。
解决办法很简单:
npx prisma generate如果你用了 path alias,也可以在 package.json 里配一个脚本,比如"db:generate": "prisma generate",平时直接pnpm db:generate。改完 schema 后先 format 再 generate,是我的习惯动作。
5.2 启动即崩:环境变量校验失败
create-t3-app 的env.mjs会在启动时校验环境变量,一旦某个变量缺失或格式不对,进程会直接退出,控制台提示也很明显。
我的排查顺序是:
- 打开
.env,对照.env.example逐项检查 - 看
DATABASE_URL是否指向有效数据库。开发环境最快的是 SQLite:file:./dev.db - 如果是 PostgreSQL,确认连接串里没有特殊字符被 shell 转义
- 确认密钥长度足够,尤其是
NEXTAUTH_SECRET或AUTH_SECRET,随便运行一段随机字符串生成命令就行
这个机制看起来冷酷,其实很温柔。把配置错误暴露在启动前,比用户访问时才发现页面崩了要好一万倍。
5.3 tRPC 接口 404 或冷启动报错
接口 404 的原因,多半是 Route Handler 路径不对。确认你的文件确实在app/api/trpc/[trpc]/route.ts,并且导出了handler:
import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; import { appRouter } from "@/server/api/root"; import { createTRPCContext } from "@/server/api/trpc"; export const runtime = "nodejs"; const handler = (req: Request) => fetchRequestHandler({ endpoint: "/api/trpc", req, router: appRouter, createContext: createTRPCContext, }); export { handler as GET, handler as POST };注意export const runtime = "nodejs"这行不能省。Next.js 默认可能把路由优化成 Edge Runtime,而 Prisma 在 Edge Runtime 下跑会有兼容性问题。加上这行,强制用 Node.js 运行时,是我排查冷启动报错时最常发现的解法。
5.4 部署到 Vercel 时的坑
t3code 这类项目部署到 Vercel 很顺,但有几个暗坑。
数据库连接数是最容易出问题的点。Vercel 的 Serverless 函数会按请求启动,如果每个实例都新建一个 Prisma 连接,高并发时数据库会被打爆。解决思路有两个:用外置连接池,比如 PgBouncer;或者给 Prisma 接上加速器。如果你只是中小流量,尽量在数据库配置里调大连接数上限,同时在全局复用 Prisma 实例。
另一个坑是 SQLite 不能用于线上。SQLite 是本地文件型数据库,部署到 Serverless 环境后根本没有持久化磁盘,所以生产环境必须切 PostgreSQL。我的做法是开发环境用 SQLite 保速度,生产环境用 PostgreSQL 保稳定,两套配置靠.env分离。
Vercel 后台别忘了填环境变量。我见过很多本地跑得好好的项目,推到 Vercel 上立刻崩,都是因为这边只提交了代码,没把.env里的变量搬到 Vercel 的 Environment Variables 面板。
5.5 常见问题速查表
| 问题 | 可能原因 | 排查/解决 |
|---|---|---|
| tRPC 请求 404 | Route Handler 路径错误或 runtime 不对 | 检查app/api/trpc/[trpc]/route.ts,加runtime = "nodejs" |
| 数据库表不存在 | 没执行迁移 | npx prisma migrate dev/migrate deploy |
| 登录接口报 UNAUTHORIZED | session 获取失败 | 检查 NextAuth 配置、secret、cookie 域名 |
| 前端类型对不上 | Prisma Client 版本过期或代码未同步 | pnpm db:generate+ 全项目 typecheck |
| 页面刷新后登录态丢失 | cookie 作用域设置不当 | 确认NEXTAUTH_URL和 domain 配置 |
| 部署构建失败 | Vercel 环境变量缺失 | 检查后台所有 server 端变量 |
6. 我的实操体会与后续扩展思路
6.1 用了一段 t3code 之后的真实感受
t3code 这套组合,我用下来最大的感受是“舒服”,不是“酷炫”。最典型的例子是:我改后端接口参数类型时,前端代码里所有调用点会同时爆红,根本不存在“我改了后端忘了前端”的情况。对于一个人维护全栈的人来说,这种编译期防线比任何测试都能兜底。
但它也有代价。tRPC 对跨团队协作其实不太友好,因为接口文档需要额外沉淀,否则后来者只能通过读代码理解业务逻辑。另外,调试 tRPC 调用时,错误信息没有 REST 那么直观,有时候需要顺着服务端堆栈定位问题。我的经验是:项目里要有一个统一的错误处理约定,并且把 Zod 校验错误通过TRPCError包装好,前端才能拿到可读的提示。
还有一个容易忽略的小知识点:不要因为 tRPC 方便就在前端把所有数据库查询逻辑都直连暴露。任何涉及多租户数据隔离的查询,都必须通过受控的 procedure 暴露,不能为了省事跳过鉴权中间件。安全和便利之间,前者永远优先。
6.2 还可以往哪些方向扩展
t3code 现在只是一套基础骨架,但它的扩展空间很大。我列几个我实际试过或正在计划的方向,供你参考。
文件上传可以用 UploadThing,它的类型安全和 T3 组合得很自然。如果觉得 NextAuth 太重,可以换成 Clerk,托管式登录能省不少事,只是要注意别被锁死。数据库查询如果越来越复杂,可以引入 t3-resolver 这类查询优化器,通过 Stored Procedure 把复杂查询放到数据库里执行。部署层面,如果不想用 Vercel,Docker + Nginx 也是完全可行的方案,只是需要自己处理环境变量和数据库连接。
最后再分享一个小技巧:如果你开始往项目里加 eslint 规则,建议把@tanstack/query的依赖状态检查也开起来,mutation 完成后忘记 invalidate 是最常见的数据不同步来源。我自己在这上面栽了好几次,后来养成了“每次 mutation 成功后立刻想一遍哪些 query 需要失效”的习惯,页面就很少出现旧数据了。
t3code 这套东西,本质上不是复杂的技术,而是一套让你把精力集中在业务上的工程组合。希望这篇分享能帮你少走点弯路,把时间省下来解决真正的问题。