t3code 是我最近整理完的一个内部项目代号。简单说,它是一套基于 TypeScript 的“现代全栈样板工程”,核心解决了我在过去两年反复遇到的一个低效问题:每次开新项目,都要从头搭 Next.js、Prisma、tRPC、Tailwind CSS、NextAuth 这一套基础设施,再把注册、登录、用户表、列表页、分页这些通用功能重新写一遍。t3code 这个项目,就是把这些反复出现的公共部分收敛成一套开箱即用的启动代码,让新项目的起点不再是“Hello World”,而是“已经能跑通注册、登录、增删改查、数据库迁移和部署的完整骨架”。
写这套模板的初衷很简单:我不缺业务想象力,缺的是时间。我相信大多数独立开发者和三五人的小团队都有同感。项目刚启动时最磨人的不是写业务代码,而是搭工程环境、配数据库、设计权限模型、调试类型报错。t3code 把这条路走了一遍并沉淀下来,让你拿到代码后十分钟内跑起来,然后把精力全部放在真正有业务价值的部分上。这篇文章我会把整个项目的设计思路、技术选型、核心实现、常见排坑和扩展建议一次说完,适合正在调研全栈脚手架、想了解 TypeScript 端到端类型安全、或者准备用 Next.js 折腾点东西的开发者。
1. 项目概述与设计思路
1.1 需求拆解:到底要解决什么问题
先讲一下当初的痛点。过去我做过不少小中型项目,技术栈换来换去,从传统的 Express + React REST 架构,到 GraphQL,再到后面流行的全栈框架。每一次切换都意味着重新处理一堆“重复但必须做对”的事情:数据校验、类型定义、接口文档、权限判断、数据库迁移、环境变量管理。这些事单独看都不难,但凑在一起,足够让一个项目前两周的进度看起来像在打转。
t3code 要解决的问题,可以拆成四个点:
- 启动速度:从 git clone 到本地跑起来,最好不超过十分钟。
- 类型安全链路:数据库表结构到 API 返回值,再到前端组件收到的 props,全程 TypeScript 自动推导,尽量不要手写类型。
- 通用能力内置:注册、登录、会话管理、用户资料、分页列表、后台管理权限,这些模块直接带着示例代码。
- 可演进:模板不是一锤子买卖,后续加新表、新路由、新页面,应该遵循同样的模式,团队协作时不需要反复解释“这个放哪”。
这四点听上去简单,实际落地时涉及大量的选型权衡。举个例子,你选择用原生 REST 接口,前端需要手写类型和 API 封装;你选择用 GraphQL,又要引入一堆 schema 和 resolver 的认知负担。对于中小型项目,这两条路都不是最优解。于是 t3code 最后收敛到一套被社区广泛验证的组合:Next.js 作为框架层,tRPC 作为接口层,Prisma 作为数据层,Tailwind CSS 负责样式,NextAuth 处理身份认证,Zod 做输入校验。
1.2 技术选型背后的权衡
技术选型不是“哪个火用哪个”,而是换一个角度看:如果项目最终规模只有二十个页面、三十个接口、五张表,那么工程复杂度必须控制在刚好够用的范围。我来复盘一下每个核心选择的理由,顺便列出备选方案为什么不选。
| 功能维度 | t3code 的选择 | 备选方案 | 我的决策依据 |
|---|---|---|---|
| 全栈框架 | Next.js 15 + App Router | Remix、Nuxt 或独立前后端 | Next.js 生态成熟,App Router 天然支持 RSC 和 Server Actions,与 tRPC 融合度好 |
| 数据层 | Prisma 6 | Drizzle ORM、TypeORM、raw SQL | Prisma Schema 一句话生成迁移脚本,类型生成和 migration 流程最顺滑 |
| 接口层 | tRPC 11 | REST + OpenAPI、GraphQL | 不需要写接口文档,前后端类型共享,过程式调用符合新手直觉 |
| 校验 | Zod | valibot、joi | 与 tRPC 插槽完美配合,输入输出都能在运行时与类型层同时校验 |
| 认证 | NextAuth v4 | Clerk、Auth.js v5、自研 JWT | NextAuth 能在数据库里存会话,配合 Prisma adapter 拿到开箱即用体验 |
| 样式 | Tailwind CSS | CSS Modules、styled-components | 开发速度快,配合 shadcn/ui 这类组件库几乎没有上手成本 |
| 包管理 | pnpm | npm、yarn | 依赖安装速度快,磁盘占用少,monorepo 支持好 |
这套组合有个很划算的连锁效应:数据库表改一个字,运行prisma generate之后,tRPC 的 router 和前端useQuery的返回类型同步变化。业务代码里不再需要手写 DTO、不维护接口文档、不担心前后端类型不同步。这在传统 REST 项目里是不可想象的。
1.3 为什么把业务收敛在 App Router 下
t3code 没有用 Nx 或 Turborepo 做成复杂的 monorepo,而是选择了一个单一应用仓库。原因是我见过太多小项目为了“后期可扩展”过早地拆包,结果每个包之间的版本升级、依赖循环、构建配置把人折磨疯。真正的 monorepo 需要工程化团队维护,对一个独立开发者来说,保持“单一应用 + 清晰模块目录”是最稳的。
在 Next.js App Router 模式下,t3code 的目录结构是这样的:
t3code/ ├── prisma/ │ ├── schema.prisma │ └── migrations/ ├── src/ │ ├── app/ │ │ ├── api/auth/[...nextauth]/route.ts │ │ ├── (auth)/login/page.tsx │ │ ├── dashboard/page.tsx │ │ └── layout.tsx │ ├── server/ │ │ ├── api/ │ │ │ ├── routers/ │ │ │ ├── root.ts │ │ │ └── trpc.ts │ │ └── db.ts │ ├── trpc/ │ │ ├── server.ts │ │ └── client.ts │ ├── components/ │ │ ├── ui/ │ │ └── forms/ │ ├── lib/ │ └── trpc/ ├── .env.example ├── Dockerfile ├── docker-compose.yml └── package.json需要注意src/server/api和src/trpc的区分。server下面的代码只会在服务端执行,比如 tRPC 路由、数据库访问、权限判断;src/trpc里放的是类型导出和客户端 provider,前端要引用的是这块。这个拆分不是说形式主义,而是避免客户端打包时意外引入 Prisma 等只能在 Node.js 环境跑的代码。
2. 核心细节解析与实操要点
2.1 端到端类型安全的完整链路
很多人听到“端到端类型安全”觉得玄乎,我拆开讲就不玄了。这条链路从数据库到 UI 分四层:
- 数据库表:在 Prisma Schema 里定义,比如一张
Post表有id、title、content、authorId、createdAt。 - 生成类型:运行
prisma generate后,Prisma Client 会生成完整的 TypeScript 类型,包括Post、Prisma.PostCreateInput等。 - 接口出入参:tRPC 的
router.input()用 Zod 定义入参,output直接 return Prisma 查询结果。此时 tRPC 会自动推导出每个 procedure 的输入和输出类型。 - 前端消费:React Query 的
useQuery或者 Server Component 直接调用 server caller,得到的返回值已经是带有类型信息的对象。编辑器里打.就能看到所有字段,写错字段名立刻飘红。
这四层之间没有任何手写类型。代码演示一下,比如post.routes.ts里写一个getPostById:
import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "../trpc"; import { db } from "~/server/db"; export const postRouter = createTRPCRouter({ getPostById: publicProcedure .input(z.object({ id: z.string().uuid() })) .query(async ({ input }) => { const post = await db.post.findUnique({ where: { id: input.id }, include: { author: { select: { name: true } } }, }); if (!post) throw new Error("Post not found"); return post; }), });前端调用:
const { data } = api.post.getPostById.useQuery({ id: postId });你会发现data.title、data.author.name的类型都是推导出来的,包含关系查询的子字段也正确的。这就是整个 t3code 最核心的价值:类型不是写出来的,是长出来的。
2.2 环境变量统一校验
环境变量是项目里最容易翻车的地方。比如DATABASE_URL少写一个端口、NEXTAUTH_SECRET忘了设置,这些错误往往到运行时才炸。t3code 基于 Zod 写了一个env.mjs,在服务启动时强制校验所有环境变量,不满足直接抛出明确错误。
我的实现思路是以src/env.mjs为中心,把所有环境变量集中管理:
import { z } from "zod"; const schema = z.object({ DATABASE_URL: z.string().url(), NEXTAUTH_SECRET: z.string().min(32), NEXTAUTH_URL: z.string().url().default("http://localhost:3000"), UPLOAD_DIR: z.string().default("./uploads"), }); export const env = schema.parse({ DATABASE_URL: process.env.DATABASE_URL, NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET, NEXTAUTH_URL: process.env.NEXTAUTH_URL, UPLOAD_DIR: process.env.UPLOAD_DIR, });注意几个细节。第一,NEXTAUTH_SECRET用min(32)强制长度,避免用户随便填个短字符串导致生产环境会话加密强度不足。第二,非必填但有默认值的变量用.default(),不会因为缺失干扰本地开发。第三,.env.example文件和env.mjs里的 schema 必须同步,我后期会加一个小脚本检测二者是否一致,防止团队里有人改了 schema 忘了更新 example。
2.3 认证与授权边界
t3code 的认证用 NextAuth,但授权逻辑不都塞在 NextAuth 里。我把它拆成两层:认证解决“你是谁”,授权解决“你能干什么”。NextAuth 负责登录、会话签发、session 读取;tRPC 中间件负责具体页面的权限判断。
比如只有注册用户能创建文章,管理员能删除任意用户,这两个权限不同。tRPC 里的保护路由这么写:
import { TRPCError } from "@trpc/server"; import { createTRPCRouter, protectedProcedure, adminProcedure } from "../trpc"; export const userRouter = createTRPCRouter({ deleteUser: adminProcedure .input(z.object({ userId: z.string().uuid() })) .mutation(async ({ ctx }) => { // 已经通过 adminProcedure,ctx.session.user.role === 'ADMIN' await db.user.delete({ where: { id: ctx.params.userId } }); return { success: true }; }), });关键点在于protectedProcedure和adminProcedure不是简单的if判断,而是封装好的中间件组合。adminProcedure基于protectedProcedure再追加一层角色校验。这比在每个接口里重复写权限判断要清晰得多。
实际项目中我建议把角色设计成数据库字段Role枚举,而不是写死在代码里。t3code 的 Prisma schema 里已经有User表和Role枚举,新的业务表只要建外键关联userId或createdBy,权限模型就跟着走。
2.4 容易被忽略的边界问题
在 t3code 开发过程中,我踩了几个很有代表性的边界坑,这里单独列出来。
第一个是Cursor 游标分页和页码分页的取舍。列表页如果数据量小,用skip/take简单省事;但如果表数据可能超过一万行,skip性能会明显下降,而且用户新增数据时重复项很多。t3code 内置的列表接口默认用 cursor 游标分页,查询参数传{ cursor?: string, limit?: number },返回{ items, nextCursor }。这个设计更适合无限滚动、评论列表、日志流这类场景。
第二个是输入校验必须发生在数据库查询前。很多新手写 tRPC procedure 时,先findMany再在代码里过滤传入的status值,这既低效又不安全。Zod 校验在input()里完成,查询条件直接由已校验的入参构造。如果入参非法,接口会在进数据库前直接抛 400,而不是把非法字符串拼进 SQL 后才被发现。
第三个是错误处理不能只丢一个 “Error” 字符串。t3code 里定义了统一的AppError类,区分BAD_REQUEST、UNAUTHORIZED、FORBIDDEN、NOT_FOUND、INTERNAL五种类型。前端拿到错误码,可以针对性地展示提示文案,而不是简单的toast.error("Something went wrong")。
3. 实操过程与核心环节实现
3.1 初始化和目录结构
把 t3code 克隆到本地后,第一步是安装依赖。我建议用 pnpm,因为 t3code 的脚本里大量使用了 monorepo 风格的依赖提升,npm 偶尔会出现幽灵依赖问题。
pnpm install cp .env.example .env然后修改.env里的DATABASE_URL指向本地 PostgreSQL。t3code 默认依赖 Docker Compose 启动一个本地 Postgres,所以最省事的方式是:
docker-compose up -d db pnpm prisma migrate dev pnpm dev这个流程在我自己的 M 系列芯片 Mac 上,从 clone 到浏览器打开登录页,大约六分钟。迁移脚本会把初始表结构建好,包括用户、会话、验证令牌和示例的Post表。打开http://localhost:3000,点击顶部注册按钮,注册账号,跳转后台,你会看到一条示例数据列表。
3.2 数据库建模与迁移
t3code 的prisma/schema.prisma里面,结构化分成了三个部分:数据源、生成器、模型。为了让团队协作时数据库改动可追踪,迁移流程被固定成两步:
pnpm prisma migrate dev --name add_post_table pnpm prisma generatemigrate dev会自动对比 schema 和数据库,生成一个迁移 SQL 文件并执行;generate更新 Prisma Client 类型。这两步缺一不可,否则你的db.post.create在编辑器里可能报类型错误。
有个实操细节:在 CI/CD 部署环境里,不要执行migrate dev,它需要交互式确认。正确做法是:
pnpm prisma migrate deploy部署命令会把 git 里所有未应用的 migration 顺序执行,且不会生成新的迁移文件,对生产库更安全。
3.3 配置 tRPC 的路由映射
t3code 的 tRPC 配置分成三个文件,职责边界非常清楚。
src/server/api/trpc.ts:创建 tRPC 实例,定义createTRPCContext和基础 procedure。src/server/api/root.ts:把各个子 router 合并到appRouter。src/server/api/routers/*.ts:放业务路由。
createTRPCContext这个函数负责把 NextAuth 的 session 和 Prisma 客户端放进 ctx,声明如下:
export const createTRPCContext = async (opts: { headers: Headers }) => { const session = await getServerAuthSession(); return { db, session, headers: opts.headers, }; };然后在root.ts里合并:
import { postRouter } from "./routers/post"; import { userRouter } from "./routers/user"; export const appRouter = createTRPCRouter({ post: postRouter, user: userRouter, }); export type AppRouter = typeof appRouter;这里有一个新手容易踩的坑:URL 路径和 router 字段名的对应关系。前端调用api.post.getPostById.useQuery(...)时,tRPC 按字段名拼接请求路径,所以postRouter对应的就是/api/trpc/post.getPostById。如果想改 URL 前缀,可以通过createTRPCClient的links配置做路径转换,但大多数情况下保持默认即可。
3.4 页面与数据获取示例
t3code 里同时展示了 Server Component 和 Client Component 两种数据获取方式,我得强调一下什么时候用哪一种。
比如 Dashboard 的简版首页,内容不需要用户交互,就直接在 Server Component 里调用 tRPC server caller:
import { api } from "~/trpc/server"; export default async function DashboardPage() { const stats = await api.post.getStats(); return ( <div> <h1>文章总数:{stats.count}</h1> </div> ); }而需要交互的列表,比如点赞、翻页、筛选,就放在 Client Component 里用useQuery:
"use client"; import { api } from "~/trpc/react"; export function PostList() { const { data, isLoading, fetchNextPage } = api.post.getPaginated.useInfiniteQuery( { limit: 10 }, { getNextPageParam: (lastPage) => lastPage.nextCursor } ); if (isLoading) return <p>加载中...</p>; return ( <ul> {data?.pages.map((page) => page.items.map((post) => <li key={post.id}>{post.title}</li> ))} </ul> ); }这里有个重要的判断:Server Component 的api.post.getStats()是直接走 Node.js 进程内调用,没有 HTTP 往返,延迟非常低;Client Component 的useInfiniteQuery则会真实走网络请求,并且自动处理 loading、缓存、错误重试。所以“不需要交互就用 server,需要状态交互就用 client”是我在模板里写死的约定,防止团队里有人为了“统一”把所有的数据请求都改成一种方式。
3.5 部署与镜像打包
t3code 的部署我留了两套方案:一是直接用 Vercel 托管 Serverless 部署,二是 Docker 自托管。如果你想快速上线,Vercel 是成本最低的。但如果你需要运行二进制的后台任务,或者数据量超过了 Vercel 的瓶颈,用 Docker 更稳妥。
我长期使用的是 Docker 方案,Dockerfile 的核心内容如下:
FROM node:20-alpine AS base RUN corepack enable FROM base AS deps WORKDIR /app COPY package.json pnpm-lock.yaml ./ RUN pnpm install --frozen-lockfile FROM base AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . RUN pnpm prisma generate RUN pnpm build FROM base AS runner WORKDIR /app ENV NODE_ENV=production COPY --from=builder /app/.next ./.next COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/prisma ./prisma EXPOSE 3000 CMD ["pnpm", "start"]有几个坑要注意。第一,一定要在 builder 阶段执行prisma generate,否则打包产物里缺少数据库客户端实例。第二,运行环境必须带上prisma/目录,否则容器启动后无法读取 schema。第三,node:20-alpine别用太新的 major 版本,某些 Prisma engine 在 Alpine 的 musl libc 环境下偶尔会出现二进制不兼容,目前 Node 20 是社区验证最充分的版本。
docker-compose 里我会把 Postgres 和 Next.js 应用分成两个容器,数据库挂载 volume,应用容器通过DATABASE_URL环境变量连接数据库。这样重装应用不会丢数据,数据库有权限控制也更清晰。
4. 常见问题与排查技巧实录
期间我自己和身边朋友用 t3code 时踩了不少坑,有些是框架自身的,有些是工具链的,都整理出来会对你更有参考价值。
4.1 开箱即用的排查速查表
| 症状 | 可能原因 | 解决办法 |
|---|---|---|
prisma migrate dev提示 P1000 | 数据库连接失败,DATABASE_URL 填错 | 检查 docker 容器是否启动,端口和用户名密码是否一致 |
tRPC 报UNAUTHORIZED但已登录 | NextAuth 的 session 在 ctx 里读取不到 | 确认getServerAuthServerSession()的 secret 与 NextAuth 配置一致,重新登录 |
前端useQuery返回 undefined 但不报错 | 在 Client Component 忘了加"use client"或缺少TRPCProvider | 检查根布局是否包裹了<TRPCReactProvider> |
构建时 TypeScript 找不到~/server/db | 路径别名没有在tsconfig.json生效 | t3code 默认配了@/*和~/*别名,如果自己改过确认paths |
| 部署后页面白屏,控制台 500 | .env里缺少NEXTAUTH_SECRET或数据库迁移未执行 | 运行prisma migrate deploy,检查环境变量是否注入到运行时 |
4.2 NextAuth 回调地址和反向代理问题
这是真实项目中最容易出幺蛾子的地方。如果你用 Docker 部署在反向代理后面,比如 Nginx 把example.com转发到本机 3000 端口,NextAuth 的 callback URL 默认读取请求头里的 host。此时如果请求头被代理剥离或改写,回跳地址会变成localhost:3000,登录后丢 session。
我的解决方法是:在环境变量里显式指定外部访问地址,同时在 NextAuth 配置里增加trustHost: true。因为 NextAuth v4 的环境变量直接读取NEXTAUTH_URL,当运行时明确指定之后,所有回跳都基于这个外部地址,不再依赖请求头。如果你在 Vercel 上部署,这个值通常比对的是站点正式域名,本地开发则保留默认的 localhost。
4.3 Prisma 连接数和连接池问题
开发环境连接数少,一般不会触发。生产环境只要用户量稍微上来一点,连接数就会暴露问题。t3code 默认用 Prisma 内置连接池,但需要注意两点:
- Node.js 的 Prisma Client 是单实例复用的。如果每个请求都创建新实例,连接池会直接被打爆。t3code 在
src/server/db.ts里做了 global 单例缓存,避免开发环境的热重载反复初始化。 - 如果你的数据库跑在云厂商的共享型实例上,连接数上限可能很低(比如 20 个)。此时可以用 Prisma Accelerate 做远程查询引擎,连接由代理管理,代码里几乎不需要改。缺点是会增加一层网络延迟,适合读多写少的场景。
我建议的方案是:应用和数据库在同一内网时用直接连接,跨网络部署时用连接池或者 Accelerate。t3code 的 Docker Compose 直接联通两个容器,这种模式效能最高。
4.4 tRPC 类型不收敛时怎么办
写完一个 router 后,前端有时会发现返回类型是unknown或者类型显示太复杂。这在 tRPC 里通常和 TS 版本或inferRouterOutputs用法有关。
t3code 里我很少主动写inferRouterOutputs,除非确实需要在一个非组件模块里引用接口类型。比如写一个工具函数:
import type { inferRouterOutputs } from "@trpc/server"; import type { AppRouter } from "~/server/api/root"; type PostOutput = inferRouterOutputs<AppRouter>["post"]["getPostById"];但注意,如果你显式给 procedure 的query函数标注了返回类型,某些情况下 TypeScript 会更倾向于使用你标注的类型而忽略实际返回值推导。最放松的做法是:不要手动标注输入输出类型,让 tRPC 从 Zod input 和 return 表达式里推。标注越多,类型越容易失真,前端拿到的类型反而不准确。
React Query 的缓存没刷新是另一个常见问题。当你在 mutation 成功后需要更新列表,默认缓存不会自动失效。t3code 在根 provider 里配置了onError全局处理,并在常用 mutation 后手动调用utils.post.getPaginated.invalidate()。这个模式比修改缓存数据更稳,因为列表的查询参数可能很多,直接失效让 React Query 重新拉取最不容易出错。
5. 扩展方向与落地经验
从 t3code 模板过渡到真实业务项目,我有几点体会和扩展方向想分享。
5.1 不要过度抽象,把业务留在服务层
模板的功能再全,也不可能覆盖每个项目的业务独特性。这个模板最大的价值是给了你一套一致的思考框架:数据库、路由、认证、错误处理、数据获取,每一层都有清晰边界。但如果你接手一个业务项目后开始疯狂抽象,把所有公共逻辑都拉到“service”层,反而会增加不必要的复杂度。
我的习惯是:领域简单的操作用 tRPC procedure 直接写,比如createPost里就三个字段,没必要再抽一层。当 procedure 超过三四十行或者需要被多个路由复用,再拆到src/server/services/里去。这个行为守则比模板本身更能防止代码腐化。
5.2 后续可以扩展的方向
t3code 目前覆盖了用户认证、文章 CRUD、分页列表、权限管理。如果你准备沿着这个模板继续做,我想到几个高价值扩展点:
- 后台任务队列:文章审核、邮件通知、文件转码这类异步任务,可以引入 BullMQ 加 Redis。t3code 的 Docker Compose 里已经预留了 redis 服务,接入成本很低。
- 对象存储:文件上传不能放在应用容器本地,否则扩容时文件丢失。建议用兼容 S3 协议的存储服务,前端直传或通过后端预签名 URL 上传。
- 系统日志:记录用户关键操作,比如登录、删除、导出。可以建一张
audit_log表,在 tRPC middleware 里做切面,避免每个 procedure 手动写日志。 - 国际化:Next.js 的 App Router 已经内置了 i18n 支持,t3code 没做进去是因为它属于业务层关注点。如果你要出海或者面向多语言用户,把文案收敛到
messages/目录,用getTranslations能平滑接入。
5.3 根据个人经验的三点补充
最后说三个我实际使用后最想强调的点。
第一,模板类项目最重要的维护项是版本升级。Next.js、Prisma、tRPC 都在快速迭代,每半年左右就得把依赖提到新版本,并跑一遍 demo 功能验证。我每次升级都会先记录在 CHANGELOG 里,方便以后回滚定位问题。
第二,文档写清楚“为什么”比写“是什么”重要。t3code 仓库里的 README 有一个“设计决策记录”章节,记录选型和取舍理由。团队里新成员读一遍,就能理解为什么用 tRPC 而不是 REST,为什么用 cursor 分页而不是 offset 分页,这比代码注释有用多了。
第三,不要迷信模板。t3code 是围绕我自己的项目习惯定制的,你可以按需取用,觉得哪块不合手就换掉,保留自己真正需要的部分。如果你只用 Next.js 和 Prisma,那 tRPC 这层可以整体去掉,前端直接用 Server Actions 照样能干活。骨架是死的,思路是活的,这套代码最大的意义不是直接拿去生产,而是给你展示一条可以落到实处的全栈工程路径。