ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

t3code:基于T3 Stack的全栈项目脚手架实战指南

t3code:基于T3 Stack的全栈项目脚手架实战指南

t3code 是我今年反复迭代过几轮的一个全栈项目脚手架,准确说是一套基于 T3 Stack(TypeScript + Tailwind CSS + tRPC + Next.js + Prisma)的实战代码库。做它的初衷很简单:不想每次开新项目都重复折腾认证、数据库、API 层这三座大山。如果你正发愁全栈项目怎么起步、T3 技术栈到底怎么串联、或者想找一个能直接抄作业的初始化模板,这篇内容就是冲着你来的。

我先说清楚这套东西能解决什么。全栈开发的痛苦往往不在业务逻辑本身,而在那些"每个项目都得做但谁都不想做"的基础工程:用户注册登录、数据库表设计、服务端接口校验、前端请求封装。t3code 把这一整套规范化、产品化地收进了一个可复用的模板库里,你 clone 下来改个名就能用,比自己从零搭能省下至少两三个工作日的配置时间。下面我从设计思路、关键实现、部署排错三个维度拆开讲,每一步都有可复现的代码和参数说明。

1. 为什么做 t3code:从一个"技术选型内耗"说起

1.1 全栈项目起步到底难在哪

我见过太多项目死在起步阶段,不是业务想不清楚,而是工程层面反复拉扯。最常见的场景是:前端用 React 生态,后端要单独起一个 Express 或 Spring Boot 服务,中间还要处理 CORS、鉴权 token 传递、API 文档维护。前后端一拆,联调成本立刻翻倍,一个字段名不统一就能耗掉半天。

我自己踩过的坑更具体:单体应用里混着 REST API 和页面路由,前端请求层每次都要手写 fetch 封装、错误码映射、loading 状态管理。一旦接口改动,前端类型根本不知道,运行时才报错。这种感觉就像两个人各自拿着一张不完整的地图拼同一座迷宫,拼不上的时候还不知道错在哪。所以我的核心诉求很明确:能在代码层面直接共享类型和业务逻辑,前后端不需要"跨语言对话"。

这引出了 T3 Stack 最关键的价值——类型安全贯穿全链路。前端调用函数式的 API,而不是拼 URL、猜返回结构。你不需要单独的 OpenAPI 文档,因为类型约束本身就是活文档。这个优势在项目中期、业务逻辑复杂以后尤其明显,改一个数据库字段,从服务端到前端组件,编译器直接帮你把所有受影响的位置标出来。

1.2 T3 Stack 为什么值得押注

t3code 采用的技术组合不是随意拼凑的。TypeScript 提供静态类型基础,这是全链路类型安全的前提;Next.js 负责页面渲染与服务端能力,App Router 的支持让路由、加载态、服务端组件都不是问题;tRPC 把 API 调用变成了类型安全的函数调用,省掉了 REST 里的 URL 约定和参数序列化;Prisma 用声明式 schema 描述数据库模型,迁移和查询都自带类型;Tailwind CSS 则把所有样式工作收敛在类名里,组件和样式不用来回跳文件。

这套组合最让我舒服的一点是"约定大于配置"。create-t3-app 出来之前,我要手动整合这些工具,光是让 Next.js 的 server端 和 tRPC 的 context 共享同一个 PrismaClient 实例就要费不少功夫。现在这套脚手架相当于把最佳实践固化成了默认配置,你不需要思考"这些库该怎么接",只需要专注写业务代码。对团队协作尤其友好:新人进组不用先读三个小时架构文档,看一眼 t3code 的目录结构就知道数据从哪来、写到哪去。

合理性问题肯定也有人提:既然 Next.js 自带 API Routes,为什么还要引入 tRPC?我的回答是,API Routes 实际上让你回到了"手写接口"的老路:每个路由文件要定义方法、解析参数、处理错误,接口间没有类型关联。tRPC 更像是在 Node 函数和浏览器调用之间建立了一条基因完全一致的双向管道,类型、参数校验、错误信息都能自动对齐。它带来的心智负担只存在于学习初期,后期省下的联调时间远超学习成本。

2. 脚手架的核心模块设计与目录规划

2.1 项目整体目录结构与职责划分

t3code 的目录规划建立在"按功能分区,而非按技术层级分区"的原则上。传统的 src/components、src/services、src/utils 会把一个业务模块切得七零八落,改一个用户功能要跨四个目录。我采用的是模块化目录,每个业务域自带一套服务端逻辑、接口定义和前端组件。

t3code/ ├── prisma/ │ ├── schema.prisma # 数据库模型声明 │ └── migrations/ # 迁移文件目录 ├── src/ │ ├── server/ │ │ ├── api/ │ │ │ └── routers/ # tRPC 路由,按业务域拆分 │ │ ├── db.ts # PrismaClient 单例 │ │ └── auth.ts # NextAuth 配置与 Session 类型 │ ├── trpc/ │ │ ├── caller.ts # 服务端调用 tRPC 的入口 │ │ ├── client.ts # 前端 tRPC 客户端初始化 │ │ └── server.ts # tRPC 实例与中间件配置 │ ├── app/ │ │ ├── (auth)/ # 登录注册相关路由组 │ │ ├── (dashboard)/ # 业务主界面路由组 │ │ └── api/trpc/ # tRPC HTTP 入口 │ ├── components/ │ │ ├── ui/ # 通用 UI 组件 │ │ └── modules/ # 业务模块组件 │ └── lib/ │ ├── validation/ # zod 校验 schema │ └── env.ts # 环境变量校验

你注意到我这里有两个路由组 (auth) 和 (dashboard),这是 Next.js App Router 的功能,括号表示它是逻辑分组、不体现在 URL 中。这样登录页和后台页可以各自拥有独立的布局文件,又共享同一个项目上下文。

PrismaClient 的实例管理是个隐藏细节。开发模式下如果热更新每次都创建一个新的 PrismaClient,会迅速耗尽数据库连接数。t3code 里做了标准化处理:在 src/server/db.ts 里用全局对象缓存单例,只有首次创建才真正初始化连接。

import { PrismaClient } from "@prisma/client"; const createPrismaClient = () => new PrismaClient({ log: ["query", "error"] }); const globalForPrisma = globalThis as unknown as { prisma: ReturnType<typeof createPrismaClient> | undefined; }; export const db = globalForPrisma.prisma ?? createPrismaClient(); if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = db;

log: ["query"]在开发环境能看到 SQL 语句,对排查 N+1 查询和慢查询很有帮助;生产环境建议去掉 query 日志,只保留 error,避免日志量过大。

2.2 认证与用户体系:一张表撑起完整权限

认证我选的是 NextAuth,并挂载到 Credentials Provider 上做邮箱密码登录。这里有个值得仔细琢磨的设计点:Credentials Provider 的 session 策略只能是 JWT,因为服务端没有维护 session 记录的能力。而 JWT 模式下的用户信息更新,会存在一条隐藏链路——数据库里改了用户名,客户端 JWT 里还是旧值。

t3code 的解法是在 tRPC 层做了一次 session 同步。我写了一个getSessionUser辅助函数,返回用户信息时以数据库为准,而不是直接信任 JWT 里的快照:

export async function getSessionUser() { const session = await getServerSession(authConfig); if (!session?.user?.id) return null; const user = await db.user.findUnique({ where: { id: session.user.id }, select: { id: true, name: true, email: true, role: true } }); return user; }

这个模式在大多数轻量级应用里足够可靠,而且避免了复杂的前后端状态同步。Prisma 用户表我建议至少包含几类字段:基础资料类(name、email、emailVerified)、安全凭证类(password/salt)、业务权限类(role、status)。role 字段推荐用 enum 而不是布尔值,为后续扩展保留空间。

model User { id String @id @default(cuid()) name String? email String @unique emailVerified DateTime? password String? image String? role Role @default(USER) status UserStatus @default(ACTIVE) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt accounts Account[] sessions Session[] } enum Role { USER ADMIN } enum UserStatus { ACTIVE BANNED }

cuid 作为主键生成器比自增 id 更适合分布式环境下的数据生成,也避免了数据迁移时自增 id 冲突的问题。

2.3 数据层与 tRPC 路由:从"接口描述"到"函数调用"

tRPC 的路由设计是 t3code 的核心亮点。它不是简单地把 REST 路径翻译成函数名,而是按业务聚合、按权限收口。我常用的分层方式是:rootRouter持有所有子路由,子路由内部再分出 public 和 protected 两个流程。

// src/server/api/routers/user.ts import { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "~/trpc/server"; import { db } from "~/server/db"; export const userRouter = createTRPCRouter({ me: protectedProcedure.query(async ({ ctx }) => { return db.user.findUnique({ where: { id: ctx.session.user.id }, select: { id: true, name: true, email: true, role: true } }); }), updateProfile: protectedProcedure .input(z.object({ name: z.string().min(2).max(30), })) .mutation(async ({ ctx, input }) => { return db.user.update({ where: { id: ctx.session.user.id }, data: { name: input.name }, }); }), });

protectedProcedure是我在 tRPC 初始化时通过中间件实现的鉴权层。每个加入中间件的 procedure 都会先校验 session,再注入当前用户上下文。这样一来,业务代码里不会出现散落的if (!session) throw error,权限控制集中在一个地方维护。

这里必须提 zod 的作用。tRPC 原生支持三类输入验证,但我一律推荐 zod,原因是 schema 可以同时承担运行时校验和 TypeScript 类型推导两个任务。你定义一次updateProfileInput,前端调用的入参类型、后端校验规则、编辑器提示就全部对齐了。

const inputSchema = z.object({ email: z.string().email(), password: z.string().min(8).refine(v => /[A-Z]/.test(v), "必须包含大写字母"), });

业务规则也建议写进 zod 的 refine 里,比如邮箱格式、密码强度、手机号正则。校验逻辑和业务逻辑不混在一起,后面写单元测试也方便。

3. 关键实现细节:从代码到落地

3.1 环境变量与配置:先把房子地基打牢

环境变量是每个项目的雷区,t3code 里我用 tRPC 官方的env.js做了一层运行时校验。它读取所有NEXT_PUBLIC_前缀和DATABASE_URL、NEXTAUTH_SECRET等关键项,如果缺失就直接抛错,不让项目在"半残状态"下启起来。

import { z } from "zod"; const envSchema = z.object({ DATABASE_URL: z.string().url(), NEXTAUTH_SECRET: z.string().min(32), NEXTAUTH_URL: z.string().url(), NEXT_PUBLIC_APP_URL: z.string().url(), }); const parsed = envSchema.safeParse(process.env); if (!parsed.success) { console.error("环境变量校验失败,请检查 .env 文件", parsed.error.issues); process.exit(1); } export const env = parsed.data;

NEXTAUTH_SECRET 的建议长度是 32 字节以上,这是 JWT 签名的基础,太短容易被暴力猜测。生成方式可以用openssl rand -base64 32。

数据库连接串里我额外吃过一个亏:Prisma 默认连接数是 CPU 核心数的两倍,serverless 环境下这种配置很容易打满数据库连接。所以我通常会手动限制连接池:

DATABASE_URL="postgresql://user:password@host:5432/db?connection_limit=5&pool_timeout=10"

connection_limit=5对绝大多数中小型应用足够,pool_timeout=10表示等待 10 秒拿不到连接就报错,避免无休止排队。

3.2 认证、数据库、tRPC 三者怎么拧成一股绳

很多人拿到 create-t3-app 初始模板后,会发现三个模块各转各的:NextAuth 登录了,但 tRPC 里拿不到 session;数据库能连上,但不清楚怎么在请求里复用连接。根因在于少了"上下文注入"这一环。

tRPC 的 context 是每次请求处理器执行前构建的。我在 server.ts 里把 session 塞进去,业务 procedure 就能通过ctx.session直接访问:

import { getServerSession } from "next-auth"; import { authConfig } from "~/server/auth"; export const createTRPCContext = async (opts: CreateTRPCContextOptions) => { const session = await getServerSession(authConfig); return { session, db, headers: opts.headers, }; };

这里有个性能细节:getServerSession 本身也会读取 cookie 并做一次 JWT 验证,但如果每个 tRPC 调用里都做一次完整 session 查询,数据库压力会很大。NextAuth 的 JWT 策略配合缓存表可以在算力和数据库之间做平衡,我建议中小项目直接用 JWT session,省掉的数据库 session 表查询对响应延迟的改善是实打实的。

前端调用侧的封装同样重要。tRPC 客户端需要一个 React Query 的 provider 包裹,然后为每个调用提供泛型类型。我在 client.ts 里做了统一配置:

import { createTRPCReact } from "@trpc/react-query"; import type { AppRouter } from "~/server/api/root"; export const trpc = createTRPCReact<AppRouter>(); export function TRPCProvider({ children }: { children: React.ReactNode }) { const [queryClient] = useState(() => new QueryClient({ defaultOptions: { queries: { staleTime: 60 * 1000 }, // 1 分钟后再标记为过期 }, })); const [trpcClient] = useState(() => trpc.createClient({ links: [httpBatchLink({ url: "/api/trpc" })], })); return ( <trpc.Provider client={trpcClient} queryClient={queryClient}> <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> </trpc.Provider> ); }

httpBatchLink默认会把同一个 tick 内的多个 tRPC 请求合并成一个 HTTP 请求,这在高频交互页面上能显著减少请求数。比如一个页面同时调用 user.me、project.list、notification.count,如果没有 batch,就是三次网络往返;有了 batch,浏览器会在一个事件循环内合并成一次请求,响应速度提升明显。

3.3 前端组件与样式体系:Tailwind 的实用写法

Tailwind CSS 在这套架构里最大的价值不是原子化的类名,而是它和组件库的良好协作。t3code 的 ui 目录下我封装了 Button、Input、Card 等基础组件,组件的变体通过 class-variance-authority 管理,保证同一套样式在不同场景下主题一致。

import { cva } from "class-variance-authority"; const buttonVariants = cva( "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50", { variants: { variant: { default: "bg-zinc-900 text-white hover:bg-zinc-800", secondary: "bg-zinc-100 text-zinc-900 hover:bg-zinc-200", destructive: "bg-red-500 text-white hover:bg-red-600", }, size: { default: "h-10 px-4 py-2", sm: "h-9 px-3 text-xs", lg: "h-11 px-8", }, }, defaultVariants: { variant: "default", size: "default" }, } );

布局方面我习惯用 Tailwind 的 utility 直接写页面结构,但复杂的表单组件建议用 shadcn/ui 这类基于 Radix 原语的组件二次封装。好处是能拿到无障碍支持(键盘导航、焦点管理),又不用全盘接受某个组件库的视觉风格。主题切换用 next-themes 包,配合 Tailwind 的 darkMode class 策略,一套代码适配 light/dark,这个在 t3code 里是默认集成的。

4. 部署与上线:一次过搞定 Vercel 与数据库迁移

4.1 部署前的检查清单

部署踩坑概率最高的地方都在"环境差异"上,本地跑得好好的,生产环境一启动就挂。我的检查清单按顺序走,能过滤掉九成问题。

第一项是环境变量同步。Vercel 的项目的 Environment Variables 面板要添加所有非 NEXT_PUBLIC 前缀的变量,特别是 DATABASE_URL 和 NEXTAUTH_SECRET。NEXT_PUBLIC_ 前缀的变量会被打包进客户端 bundle,这类变量在生产环境里必须用NEXT_PUBLIC_APP_URL指到正式域名,否则会出现本地跳转正常、线上跳转到 localhost 的诡异问题。

第二项是数据库迁移。生产库不建议用prisma db push,它只同步 schema、不产出迁移文件,历史不可追溯。正确方式是prisma migrate deploy,它只执行 migrations 目录里的迁移,不检查 schema drift。所以部署流程里 CI 步骤应该是:切到生产分支合并 PR 后,执行迁移、再构建应用。

# 部署脚本里执行 npx prisma migrate deploy npx prisma generate npx next build

第三项是检查 NextAuth 的诚实回调地址。NEXTAUTH_URL 必须与生产域名完全一致,如果域名迁移过,新旧域名之间的 session 兼容也要提前规划。

4.2 生产环境容易踩的 5 个坑

第一个坑:batch link 在服务端调用时失效。t3code 中如果你在 RSC 或 getServerSideProps 里调用 tRPC 的 server caller,httpBatchLink 不能走 HTTP,应该直接用createCaller在内存中调用,避免内部请求环回。

第二个坑:Prisma 实例被创建多次。serverless 环境的冷启动会让模块代码重复执行,所以必须用全局单例模式,也就是前面提到的globalForPrisma。这一点我在不同平台(Vercel、Netlify)都遇到过,一旦连接数打满,数据库会报too many connections,而且这个错误具有传染性,一个实例卡死会拖慢所有实例。

第三个坑:edge runtime 里用不了重依赖。NextAuth 的 Credentials Provider 会用到 bcrypt 这类原生库,不能跑在 Edge Runtime 上。如果页面路由设了export const runtime = "edge",必须确认它调用的逻辑不涉及 Prisma 和 bcrypt,否则直接运行时崩溃。

第四个坑:环境变量在构建时读取。Next.js 构建时会把 NEXT_PUBLIC_ 变量的值写成静态文本,如果构建机和运行时环境变量不一致,会出现"构建成功、运行错误"。我的规避方式是把这类变量在构建命令里显式传一次。

第五个坑:数据库地域跨区访问。如果数据库在东京、应用函数在弗吉尼亚,每次查询会有 100 毫秒以上的网络延迟,对交互型应用影响很大。建议把生产数据库和 serverless 函数放在同一区域,连接延迟能从百毫秒级降到个位毫秒级。

5. 常见问题与排错实录

5.1 五类高频报错速查表

下面这张表是从 t3code 用户反馈和自己的修 bug 记录里整理出来的,基本覆盖了会把新人卡住的场景。

报错信息或现象根本原因处理办法
❌ prisma::error(file_path)或查询返回空schema 变更后未生成 client运行npx prisma generate
NEXTAUTH_SECRET not set环境变量缺失或长度不足用 openssl 生成 32 字节以上随机串
experimental use of "useCacheNext"。之类编译警告Next.js 版本与依赖缓存策略不一致升级 Next.js 到当前稳定版,重新安装依赖
tRPC 调用报 405 Method Not AllowedHTTP 方法和路由方法不匹配,比如 mutation 写成了 query检查 procedure 类型与前端调用是否一致
部署后首屏白屏服务端组件尝试访问浏览器 API,如 window用typeof window !== 'undefined'做守卫或改用客户端组件
too many connectionsPrismaClient 实例被重复创建确认使用全局单例,调小连接池参数

第二类报错值得展开说。experimental类警告我建议不要假装看不见,它往往意味着某个依赖包用了不稳定的 API,未来升级版本就会 break。我用npm ls检查依赖树,确认没有两个版本冲突的包,然后统一 lockfile。

5.2 我的排错思路与工具

排错三分靠经验、七分靠流程。我自己固定用的是"三层定位法":先确认网络层(请求有没有发出、返回什么状态码),再排查应用层(tRPC procedure 有没有执行),最后落到数据层(SQL 是否按预期执行)。

网络层可以用浏览器 DevTools 的 Network 面板,看请求 URL、Method、Payload。如果请求根本没发出去,问题通常在前端调用封装或路由守卫;如果请求发出但返回 500,问题大概率在后端 procedure 或数据库。

应用层我会在 tRPC router 里临时加console.log,配合 Vercel 的 Function Logs 查看输出。这里有个小技巧:生产环境不要再用console.log排查,直接接 Sentry 或 Logtail,记录错误堆栈和业务上下文。

数据层如果有疑问,打开 Prisma 的 query 日志,看执行的 SQL 和参数。N+1 问题也是在这一层暴露得最明显:两条看似独立的查询,实际可以用 include 或 select 合并成一条。

我还有一个个人习惯:把可复现的报错写成 test case 加进 CI。比如某个 zod schema 在边界值下校验失败,就固化成一个最小测试。这样后续改代码时,这类错误不会第二次溜进来。

5.3 给新手的三个实操建议

第一,不要跳过prisma migrate dev --name init这一步。有些教程图省事用db push,开发阶段确实方便,但一旦有了线上数据,再想生成干净的迁移历史就很难了。从第一天就用迁移文件管理 schema,后面每次改动都能回放。

第二,tRPC 里 input 校验没过的错误信息,默认对前端是"内部错误"级别的模糊提示。调试时可以在 development 环境开启onError回调,把报错原文打印出来。上线前再关掉,避免暴露内部结构。

第三,写业务代码前先把"当前用户是谁"这个问题解决。t3code 的 protectedProcedure 已经帮你注入了 ctx.session,但如果你的页面需要服务端拿用户信息,记得直接用 getSessionUser 而不是在前端二次请求。服务端渲染的能力不用白不用,还能少一次网络往返。

6. t3code 后续还能怎么扩展

到这一步,t3code 已经能支撑一个中小型全栈产品从开发到上线的完整闭环。如果你愿意继续往下走,我个人建议在三个方向做扩展。

第一个方向是把数据库迁移流程接入 CI。现在迁移是手动在部署前执行,更好的方式是写一个 GitHub Action:PR 合并到 main 时自动跑migrate deploy,成功后触发 Vercel 构建。这样"改 schema 发布"变成一条自动流水线,降低人为遗漏的风险。

第二个方向是引入测试框架。t3code 目前偏重运行时正确性,但单元测试和集成测试还留有空间。Vitest 搭配 Prisma 的内存数据库可以实现隔离测试,tRPC 的 caller 也可以直接单测 procedure 的入参与出参,这块做完以后,脚手架的健壮性会有质的提升。

第三个方向是沉淀更多业务模块。认证、用户资料、文件上传是每个项目都会遇到的通用能力,未来可以把它们从 t3code 抽成独立的 npm 包或插件。这样新项目能按需组合模块,不用每次 clone 全套。

我实际用下来的体会是:T3 Stack 的学习曲线不算陡,真正花时间的是理解"上下文传递"和"类型安全链路"这两件事。t3code 把这两件事做成了约定俗成的模板,等于把经验沉淀成了代码。如果你正准备开一个新项目,不妨先在这个基础上把业务逻辑铺开,等跑通第一个版本后,再回头审视哪些地方需要替换成你自己的方案——工程上的"照抄"从来不是目的,用最短路径建立正确的心智模型才是。

返回列表