搞过全栈项目的人应该都有同感:搭项目骨架这件事,做一次两次还好,做多了真的会烦。每次都要重新配 TypeScript、接数据库、写 API 层、调鉴权、配样式框架,光是把这些零碎的东西拼起来就能耗掉大半天,还没写到一行业务代码。我之前重复踩了好几轮这种坑,后来干脆把自己的选型和配置沉淀成了一套模板,起名叫 t3code。这篇文章就是把我这套项目的完整设计思路、关键实现和踩过的坑整理出来,算是一份带实战细节的全栈模板复盘。不管你是打算自己从零搭一套工程,还是想找一个能直接开干的全栈底座,这里面讲的东西应该都用得上。
1. t3code 的立项动机:我到底想解决什么问题
1.1 模板类项目的通病:要么太重,要么太散
先说背景。市面上其实不缺全栈模板,create-next-app、各种 boilerplate、还有社区里一堆 starter,看起来都挺齐全。但真拿它们开工的时候你就会发现,很多模板的问题是"什么都有,但什么都绑死了":有的默认塞了 Docker、K8s 编排,本地跑起来先要拉一堆镜像;有的把状态管理、组件库、图表库全配好了,但你根本用不到,删还不敢乱删,怕拆坏了哪块的依赖。
我想要的模板有一个非常明确的标准:它应该是一套"最小可运行但五脏俱全"的全栈骨架,能把项目从零带到"用户能登录、数据能读写、页面能上线"这个状态,但不要夹带任何多余的私有逻辑。t3code 的核心思路就是做减法。
1.2 全栈团队里最常见的协作痛点
还有一个更实际的出发点。我之前在一个小团队里带前端,前后端分离久了有一个老问题:接口定义靠文档,文档一不及时更新,前端就只能去翻后端代码猜字段。联调阶段半数时间都浪费在"这个字段到底是 string 还是 null"上。后来我们开始尝试端到端类型安全的技术栈,把 API 层的类型直接从后端函数推给前端调用方,这个痛点才真正缓解。
t3code 的底层逻辑就是这个:让数据模型、接口契约、组件 props 尽量共享一套类型来源,少一个文档,少一层猜测。它不是某个框架的银弹,而是一种工程组织方式的实践集合。
1.3 这个模板到底包含什么
t3code 最终落地的技术栈和核心能力如下表所示,后面我会逐个拆开讲为什么选它、怎么配它:
| 层级 | 选型 | 承担职责 |
|---|---|---|
| 框架 | Next.js(App Router) | 页面渲染、路由、服务端组件 |
| 语言 | TypeScript | 全链路类型约束 |
| API 层 | tRPC | 端到端类型安全的过程调用 |
| ORM | Prisma | 数据建模、迁移、查询 |
| 数据库 | 默认 SQLite,可切换 PostgreSQL | 存储 |
| 鉴权 | NextAuth(Auth.js) | 会话、OAuth 登录 |
| 样式 | Tailwind CSS | 原子化样式 |
为什么要强调"默认 SQLite,可切换 PostgreSQL"?因为模板的核心目标是让任何人 clone 下来之后能在五分钟内跑起来,SQLite 零配置,文件即库,最适合起步。等业务真的需要往高并发数据库迁,Prisma 的 datasource 改一行、重新跑一次迁移就能切走。这个取舍在后面部署章节我还会细说。
2. 核心选型复盘:每个技术决策背后的为什么
2.1 为什么是 tRPC 而不是 REST 或 GraphQL
这是 t3code 里最值得聊的一个决策。REST 的问题是类型割裂:你写一个 GET /api/users,后端返回什么结构,前端只能靠"约定"去理解,跑起来之后字段对不上,报错要到运行时才暴露。GraphQL 解决了部分问题,但引入了 schema 语言、resolver 分层、客户端缓存这些额外复杂度,小团队用起来维护成本偏高。
tRPC 的思路非常直接:你的后端不再是"暴露 URL",而是"暴露函数"。前端调用一个userRouter.list(),它的入参类型和返回值类型全部由 TypeScript 静态推导,写错字段在编译期就会炸。这相当于把"接口文档"直接内置到了类型系统里。实测下来,自从切到 tRPC,前后端的"字段歧义"类 bug 下降得非常明显,因为那里根本没有歧义的空间。
2.2 Next.js App Router 和服务端组件的取舍
t3code 采用 App Router。为什么不用 Pages Router?Pages Router 已经很成熟,但 App Router 的 Server Component 模式更适合全栈项目的组织方式:数据读取发生在服务端,组件直接拿数据渲染,不需要再开一个 loading 态的客户端请求。
当然 App Router 也不是没有成本。它引入的 caching 语义——fetch缓存、路由缓存、动态渲染——对新手来说非常容易踩坑。我见过不少人在页面里改了数据,刷新还是旧内容,就是因为不了解 Next.js 的缓存层级。t3code 里我对动态页面的策略很明确:需要实时数据的地方用connection()或合理配置动态渲染,不需要过度优化的静态页面保持默认。这个经验在第四章展开。
2.3 Prisma:数据模型即类型来源
数据库选型上的核心件是 Prisma。它在 t3code 里的作用是"让数据库表结构变成 TypeScript 类型"。你在schema.prisma里定义一个Usermodel,Prisma Client 就会生成对应的类型,tRPC 的返回值直接引用这个类型,前端拿到的类型和表结构完全一致。
模板里我默认建了三个核心 model:User、Account、Session(NextAuth 官方推荐的最小模型集),再加一个演示用的Post,用来展示关联查询和鉴权保护。我觉得模板级别的 schema 尽量克制,只保留能说明问题的表,业务表等真实项目里再去扩展。
2.4 样式方案:Tailwind 的形态
Tailwind 在这个模板里没什么花活,就是最朴素的用法。选它主要是两个原因:一是原子类写法让组件的样式依赖非常局部,删组件不用怕残留样式;二是配合 Next.js 的 postcss 配置非常顺,不需要额外引入 CSS-in-JS 的运行时成本。
我个人对 Tailwind 的态度是:不要神化它,也不要为了少写几个 class 硬上抽象组件。模板里我只提供了基础配置和几个简单示例,比如按钮、表单控件的类组合,真正业务的样式体系应该由使用者在项目里自己长出来。
3. 从零搭起 t3code:目录结构与关键代码拆解
3.1 工程初始化与依赖清单
我是用create-next-app做底座起步的,因为 Next.js 的官方脚手架已经把 ESLint、TS 配置、Tailwind 都处理好了,自己叠加这些反而容易版本不匹配。装完底座之后,再装的依赖分成两组:
# 服务端与 API 层 npm install @trpc/server @trpc/client @trpc/react-query @tanstack/react-query superjson zod # 数据层与鉴权 npm install @prisma/client prisma @auth/prisma-adapter next-auth这里有个小技巧:superjson一定要装。tRPC 默认序列化只能处理 JSON 能表达的数据,Date、BigInt 这类类型直接传会出问题,配了 superjson 转换器之后,数据库里读出来的Date字段就能原样传到前端,不用再手动toString()。
3.2 目录设计:关注点隔离是模板的灵魂
t3code 采用 Next.js App Router 的标准结构,加上 tRPC 的 router 分层。我实际落地的目录如下:
src/ ├── app/ │ ├── api/auth/[...nextauth]/route.ts # NextAuth 接管鉴权接口 │ ├── layout.tsx # 根布局 │ └── page.tsx # 首页(含示例逻辑) ├── server/ │ ├── db.ts # Prisma Client 单例 │ ├── auth.ts # NextAuth 配置 │ └── api/ │ ├── root.ts # tRPC 根 router 聚合 │ ├── trpc.ts # router/procedure 工厂 │ └── routers/ │ ├── post.ts # 示例 post router │ └── user.ts # 用户相关过程 ├── trpc/ │ ├── client.ts # tRPC React 客户端 │ ├── server.ts # 服务端上下文创建 │ └── caller.ts # 服务端直接调用入口为什么要把server和trpc独立出app?因为 Next.js App Router 的app目录只应该放路由和页面组件,业务逻辑如果也塞进去,目录会迅速腐烂。server目录放所有 Node 端代码,trpc目录放客户端和服务端的衔接代码,肉眼可见地清晰。
3.3 tRPC 的三个关键文件:工厂、上下文、根路由
tRPC 的使用有三个绕不开的概念:procedure 工厂、上下文、根 router。t3code 里最核心的server/api/trpc.ts长这样:
import { initTRPC, TRPCError } from "@trpc/server"; import superjson from "superjson"; import { ZodError } from "zod"; import type { Context } from "./context"; const t = initTRPC.context<Context>().create({ transformer: superjson, errorFormatter({ shape, error }) { return { ...shape, data: { ...shape.data, zodError: error.cause instanceof ZodError ? error.cause.flatten() : null, }, }; }, }); export const router = t.router; export const publicProcedure = t.procedure; // 需要登录才能调用的过程 export const protectedProcedure = t.procedure.use( ({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { ...ctx, user: ctx.session.user } }); } );上下文文件server/api/context.ts负责把数据库会话和登录态注入到每个调用里:
import { db } from "@/server/db"; import { getServerSession } from "next-auth"; import { authOptions } from "@/server/auth"; export const createTRPCContext = async (opts: { headers: Headers }) => { const session = await getServerSession(authOptions); return { db, session, ...opts }; };这里要强调的错误处理逻辑:errorFormatter里把 Zod 的校验错误结构化返回,前端就能直接拿到"哪个字段错了、为什么错",表单校验的体验会好很多。这个细节是模板和玩具项目拉开差距的地方。
根路由server/api/root.ts把所有子 router 聚合起来,客户端在初始化时指定地址指向它,类型就能自动推导到全站:
import { postRouter } from "@/server/api/routers/post"; import { userRouter } from "@/server/api/routers/user"; import { createTRPCRouter } from "@/server/api/trpc"; export const appRouter = createTRPCRouter({ post: postRouter, user: userRouter, }); export type AppRouter = typeof appRouter;3.4 Prisma Schema:最小但完整的模型设计
schema.prisma是数据层的源头。t3code 里我保留了 NextAuth 需要的三张表和一张业务示例表:
generator client { provider = "prisma-client-js" } datasource db { provider = "sqlite" url = env("DATABASE_URL") } model User { id String @id @default(cuid()) name String? email String? @unique emailVerified DateTime? image String? accounts Account[] sessions Session[] posts Post[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Account { id String @id @default(cuid()) userId String type String provider String providerAccountId String refresh_token String? access_token String? expires_at Int? token_type String? scope String? id_token String? session_state String? user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([provider, providerAccountId]) } model Session { id String @id @default(cuid()) sessionToken String @unique userId String expires DateTime user User @relation(fields: [userId], references: [id], onDelete: Cascade) } model Post { id String @id @default(cuid()) title String content String? published Boolean @default(false) authorId String author User @relation(fields: [authorId], references: [id], onDelete: Cascade) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }注意cuid()这个默认值,比自增整数强在分布式场景下不会撞 id,模板里我用它不用autoIncrement()。onDelete: Cascade也是必须的,否则用户删除时关联记录会报外键约束错误。
3.5 一个完整的受保护流程:创建文章的链路
光有静态代码说明不了问题,我拿"登录用户创建文章"这个完整流程来演示 t3code 的类型安全链路是怎么跑通的。
先定义输入的类型校验,zod承担这个任务:
import { z } from "zod"; const createPostSchema = z.object({ title: z.string().min(1, "标题不能为空").max(100), content: z.string().optional(), }); export const postRouter = createTRPCRouter({ create: protectedProcedure .input(createPostSchema) .mutation(async ({ ctx, input }) => { return ctx.db.post.create({ data: { title: input.title, content: input.content, authorId: ctx.user.id, // 从 session 注入,不由客户端传 }, }); }), list: publicProcedure.query(async ({ ctx }) => { return ctx.db.post.findMany({ orderBy: { createdAt: "desc" }, include: { author: { select: { name: true } } }, }); }), });注意authorId这个字段:它来自ctx.user.id,也就是从登录会话拿到的,不是客户端传上来的。这是一个重要的安全习惯——永远不要信任前端传入的归属字段,否则用户可以随便把文章挂到别人名下。
前端调用侧,配合@tanstack/react-query,整个链路类型全通:
"use client"; import { api } from "@/trpc/client"; export function PostCreator() { const utils = api.useUtils(); const createPost = api.post.create.useMutation({ onSuccess: () => utils.post.list.invalidate(), }); return ( <form onSubmit={(e) => { e.preventDefault(); const formData = new FormData(e.currentTarget); createPost.mutate({ title: String(formData.get("title")), content: String(formData.get("content")), }); }} > <input name="title" placeholder="标题" /> <textarea name="content" placeholder="正文" /> <button type="submit" disabled={createPost.isPending}> 发布 </button> </form> ); }useMutation的入参类型被 tRPC 直接从后端的 zod schema 推导过来,你在前端把title拼错成tile,编辑器立刻标红。发布成功之后主动invalidate掉post.list查询,列表自动刷新,不用手写缓存更新逻辑。这套交互模式非常顺,也是我为什么在模板里强依赖 react-query 的原因。
3.6 NextAuth 接入:Provider 和 Session 的一体化配置
鉴权配置放在server/auth.ts里,通过 PrismaAdapter 把会话持久化到数据库:
import { PrismaAdapter } from "@auth/prisma-adapter"; import NextAuth, { type NextAuthOptions } from "next-auth"; import GitHub from "next-auth/providers/github"; import { db } from "@/server/db"; export const authOptions: NextAuthOptions = { adapter: PrismaAdapter(db), providers: [ GitHub({ clientId: process.env.GITHUB_CLIENT_ID!, clientSecret: process.env.GITHUB_CLIENT_SECRET!, }), ], session: { strategy: "database" }, // 数据库会话策略 callbacks: { session: ({ session, user }) => ({ ...session, user: { ...session.user, id: user.id }, }), }, };这里我特意固定了strategy: "database"。JWT 策略虽然无状态,但拿不到数据库里用户的最新信息,用户改名或封禁后要等 token 过期才生效;数据库策略的会话存储在 Session 表里,信息实时。代价是每个请求多一次会话表查询,但对于模板级别的项目完全可接受。
4. 从开发到上线的实测记录:那些文档没写的坑
4.1 缓存把你"骗"了:App Router 的动态渲染边界
这是 t3code 开发过程中排得最久的一个问题。现象是:通过 tRPC 创建了一篇文章,列表页刷新后仍然看不到。第一直觉是缓存污染,但我排查了fetch缓存、react-query 缓存都没发现问题。最后定位到根因是 Next.js 的静态优化:app目录下没有标记动态渲染的页面,在构建时会被预渲染成静态 HTML,tRPC 的查询结果虽然在客户端发起,但首屏 HTML 里旧的列表内容已经被固化,客户端 hydration 之后数据才更新,刷新瞬间显示旧内容就很正常了。
解决办法是在需要实时数据的地方声明动态渲染。Next.js 15 里推荐的做法是使用await connection()之类的动态 API:
// app/posts/page.tsx import { connection } from "next/server"; export default async function PostsPage() { await connection(); // 标记为动态渲染 // 页面其余逻辑... }更简单的方案是直接声明export const revalidate = 0或export const dynamic = "force-dynamic"。我的建议是:只有确实不需要实时性的页面才去享受静态优化的红利,其他页面一律显式动态,否则上线后"明明改了数据库页面却不是新的"这种问题会让你怀疑人生。
4.2 tRPC 版本升级的连锁反应
t3code 最初按 tRPC v10 写的,后来升到 v11,API 变化比想象中大。最典型的是createTRPCNext被移除了,React Query 的集成方式改成了createTRPCReact配合直接的 query client。很多旧教程的代码在 v11 下直接跑不起来。
升级 v11 之后,客户端初始化变成这样:
import { createTRPCReact, httpBatchLink } from "@trpc/react-query"; import { inferRouterInputs, inferRouterOutputs } from "@trpc/server"; import type { AppRouter } from "@/server/api/root"; import superjson from "superjson"; export const api = createTRPCReact<AppRouter>(); export const getBaseUrl = () => { if (typeof window !== "undefined") return ""; if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`; return `http://localhost:${process.env.PORT ?? 3000}`; }; export const trpcClient = api.createClient({ links: [httpBatchLink({ url: `${getBaseUrl()}/api/trpc`, transformer: superjson })], });升级过程中注意点有两个:一是transformer的位置,v11 里从全局配置挪到了每个 link 里;二是 React Query 版本要配套,tRPC v11 要求 TanStack Query v5,API 里isLoading改成了isPending,这些细节都容易在组合时出错。我在文档里专门把这两个版本的差异标注出来,就是不想后人再去翻 GitHub issue 出这一身汗。
4.3 环境变量管理与多环境切换
模板里.env.example是必须的,内容如下:
DATABASE_URL="file:./db.sqlite" NEXTAUTH_SECRET="" NEXTAUTH_URL="http://localhost:3000" GITHUB_CLIENT_ID="" GITHUB_CLIENT_SECRET=""NEXTAUTH_SECRET是登录会话的签名密钥,直接决定登录态安全。很多人第一次部署时忘了配这个变量,结果每次重启服务会话就失效。生成随机密钥可以用一行命令:
openssl rand -base64 32线上环境的话,GitHub OAuth App 的回调地址务必填生产域名的/api/auth/callback/github。我见过太多人 OAuth 配置报redirect_uri错误,最后发现是回调地址少了路径。这个坑排起来其实不复杂,但第一次遇到确实会懵。
4.4 部署:Vercel 一键 vs 服务器自托管
t3code 的默认部署目标是 Vercel,因为 Next.js 和它几乎是原生配合,环境变量在控制台里配好,Push 代码就自动构建上线了,不用写 Dockerfile,不用配 Nginx。唯一的注意点是 SQLite 文件是写入本地磁盘的,在 Vercel 的无状态函数环境里无法持久化,所以模板里对 Vercel 部署的推荐做法是切 PostgreSQL。
如果选择自托管,我建议用 Docker Compose 起一个 node 容器加一个 postgres 容器,服务端口暴露到宿主机。Next.js 的自托管需要处理.next产物的持久化,最好把 build 产物放到挂载卷里,避免每次重启容器都重新构建。对于个人项目级别的并发量,这个方案足够了。
4.5 运营环境里最容易忽略的性能细节
t3code 在实战环境跑过一段时间之后,我发现几个性能细节值得每个全栈开发者注意:
一是批量查询的 N+1 问题。Prisma 的关联字段,如果在一个列表循环里逐个findUnique查作者信息,就是典型的 N+1。正确做法是像前面 demo 那样用include一次带出。
二是tRPC 批处理链接。httpBatchLink会把同一事件循环内的多个 tRPC 调用合并成一个 HTTP 请求,这对列表页同时拉多个查询的场景帮助非常大。但如果你的某个查询特别慢(比如几秒钟的报表),建议单独拆出去用独立 link,否则一个慢请求会拖着整个 batch 队列。
三是superjson 的序列化开销。它带来的便利是 Date 类型直传,但序列化成本略高于 JSON。数据量大的查询,不要迂腐地全场使用 superjson,可以在个别 router 上评估是否绕过。
5. 模板的边界与后续扩展:它不是什么和能变成什么
5.1 t3code 特意没做的事
做模板最容易失控的就是往里面加东西。t3code 明确不包含的东西,我捋了一个清单:
- 不内置状态管理库(Zustand/Redux)。服务端组件 + tRPC 查询已经完全覆盖了大部分数据流,客户端状态等真正需要时再引入。
- 不内置组件库(shadcn/ui 这类)。组件库是强口味绑定,模板给了 Tailwind 基础,使用者自己去接自己喜欢的组件体系。
- 不做复杂的 RBAC 权限系统。模板只提供"登录/未登录"两态保护,多角色权限是业务逻辑,硬塞进模板只会让新人看不懂。
这些"不做"和"做"同样重要。模板的生命力在于克制,每个人拿到之后能在自己的方向上自由生长,而不是被作者的偏好绑架。
5.2 从模板到业务,我的迁移路线建议
拿 t3code 开一个新项目的流程,我在团队内部定的规范是这样的:
第一步,clone 模板,改包名,跑一遍prisma migrate dev生成初始表结构。 第二步,先实现核心实体的 model 和对应的 tRPC router,不要急着写页面。 第三步,页面从数据流反推:先写 tRPC 查询,让useQuery给你类型,再写 UI。 第四步,加一组端到端测试,至少覆盖"登录→创建资源→读取资源"这条主链路,防止后续迭代把类型安全的链路跑崩。
前两步花的时间通常不到一小时,这意味着一个需求的"冷启动成本"被压到了极低,这也是我建 t3code 最实在的收益。
5.3 我的后续计划
t3code 目前的版本在内部已经跑过两个真实项目,一个是后台管理面板,一个是内容展示站点。下一步我打算补几块东西:加一层简单的日志中间件,把每个 tRPC 过程的耗时和错误率暴露出来;补一个 Docker Compose 的 postgres 配置模板,让自托管体验更顺;再往文档里补一些常见问题的排查索引。
我把这套东西放出来,最大的希望是它能帮你减少项目从零到一的时间损耗。如果你拿它跑通了一个自己的项目,或者在里面发现了更有意思的工程写法,欢迎顺着这个思路继续往下做。全栈类型的工程没有标准答案,但有一批人各自实践、互相参照,总归能往前走得更稳一些。