ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

t3code全栈模板:基于T3 Stack的Next.js+TypeScript项目实战

t3code全栈模板:基于T3 Stack的Next.js+TypeScript项目实战

1. 项目定位:t3code到底在解决什么问题

先说结论:t3code 不是一个官方框架,也不是某个大厂的项目代号,它是一个我在社区里泡了很久之后,决定自己上手从零搭出来的一个全栈应用模板。名字起得很随意——因为技术选型恰好是 T3 Stack(Next.js + TypeScript + tRPC + Tailwind + Prisma + NextAuth),加上我习惯用“code”做项目后缀,于是就叫了 t3code。结果没想到这个名字在社区里挺吃香,陆陆续续有十几个朋友问我要过实现方案和源码结构。

这个项目实际想做的是这么一件事:把现代全栈开发里最常见的需求——用户注册登录、受保护路由、数据模型设计、服务端逻辑封装、API 调用、部署上线——全部塞进一个足够精简但又足够完整的模板里。它不是那种堆满 Demo 页面的玩具项目,而是以“可以直接拿来改造成真实产品”为目标的脚手架。换句话说,t3code 就是一套“默认最佳实践”的项目骨架,你拿到手之后,改改样式、调调数据模型、加几个业务 Router,就能往生产环境推。

很多刚接触 T3 Stack 的人最大的困惑是:tRPC 到底跟 REST 有什么区别?Prisma 选 SQLite 还是 PostgreSQL?NextAuth 的 session 怎么在服务端和客户端共享?t3code 的价值就在这里——它把这些问题一次性用代码回答掉。我见过太多人一开始很兴奋地 create-t3-app,结果三小时后卡在 tRPC 的 Context 类型报错上,或者在 NextAuth 的 JWT 回调里 debug 了一个通宵。这篇博文会把 t3code 从设计思路到具体实现、再到部署过程中踩过的坑,全部过一遍。适合谁看?如果你正在准备用 T3 Stack 做一个中型全栈项目,或者你只是想知道现代 TypeScript 全栈开发到底长什么样,这篇内容都值得你花十分钟读完。

2. 技术选型背后的取舍逻辑

2.1 为什么是 Next.js App Router 而不是 Pages Router

刚开始构思 t3code 的时候,我也纠结过这个问题。Pages Router 成熟稳定,文档多,踩坑少;App Router 相对年轻,但它是 Next.js 未来的方向。后来我决定直接上 App Router,理由有三点:第一,Server Component 能显著减少客户端 bundle 体积;第二,Route Handler 和 Server Action 的边界更清晰;第三,tRPC 在 App Router 下的集成方案已经非常成熟,不存在“选型太激进导致无解”的风险。

实际体验下来,App Router 的布局系统(Layout)非常利于项目拆分。t3code 里我把整体布局分成了三块:根布局负责全局字体和主题,主内容布局处理导航栏与页面容器,而受保护页面(比如用户中心)则单独用一层(protected)路由组包起来,在里面统一做鉴权校验。这种结构的好处是:鉴权逻辑不用在每个页面里重复写,中间件或者布局层拦截一次就够了。Pages Router 时代你得靠_app.tsx加 HOC 或者每个页面手动判断,相比之下 App Router 的处理方式优雅得多。

npx create-next-app@latest t3code --typescript --tailwind --eslint --app

2.2 tRPC 为什么能替代 REST API

这是 t3code 整个项目里最核心的一个设计决策。传统前后端分离项目里,前端要拿数据就得定 REST 接口、写接口文档、维护请求函数、处理类型不一致的 bug。tRPC 做的事情听起来有点“违背常识”:它不走 HTTP 的 RESTful 语义,而是直接调用服务端函数,并且类型全自动推导。你不需要手写路由路径,不需要手写请求参数类型,不需要手写响应类型——类型就像变魔术一样从前端一路串到数据库查询层。

我知道有人会质疑:Postman 怎么办?第三方客户端怎么对接?内部工具怎么调?我的观点很直接:如果 API 的消费者只有你自己的前端,tRPC 是绝对的最优解,没有之一。它能把原本 200 行的 API 定义文件压缩到零,但如果你有开放平台需求、要对接第三方系统,那就不应该用 tRPC 暴露业务接口。数据库模型、业务逻辑和前端组件之间的类型安全,在开发体验上的提升是实打实的。我在 t3code 里做过一个统计:一个标准 CRUD 模块,用 REST 方案大概要写 5 个文件(类型定义、路由、控制器、请求函数、状态管理),换成 tRPC 之后就变成两个文件(Router + Hook),工作量差了不止一半。

2.3 Prisma + SQLite 的起步策略

数据库方面,T3 Stack 官方模板默认让你选 SQLite,很多人不理解:生产环境哪有人用 SQLite 的?但 t3code 的定位决定了它在起步阶段必须用 SQLite。原因很简单:新手可以零配置直接npx prisma db push跑起来,没有安装 PostgreSQL 的环境负担。如果你一开始就上 PG,光数据库连接、用户权限、连接池这些前置配置就能把新手劝退。

我建议的实际策略是:本地开发用 SQLite 先把业务逻辑跑通,等到真正准备部署到生产环境前,再把 SQLite 切换成 PostgreSQL。Prisma 在这块做得很好,schema 文件里改一下 datasource provider,删掉几处 SQLite 特有的语法,90% 的模型能直接平移。t3code 的 schema.prisma 里我会刻意避开 SQLite 的@db.Text之类的方言写法,尽量只用通用类型,这样切换成本能降到最低。

2.4 Tailwind + shadcn/ui 的组合拳

样式这块其实是最不需要纠结的。Tailwind 负责原子化类名,shadcn/ui 提供复制粘贴到项目里的无依赖组件,两者搭配起来没有一丁点配置负担。在 t3code 里我没用任何 UI 组件库的 npm 包,而是直接复制了 Button、Input、Card 等十几个 shadcn/ui 组件源码进项目。这样做的最大好处是:不会出现组件库升级导致 breaking change,样式想怎么改就怎么改,整个项目对第三方依赖的耦合降到了极低水平。

在做过的几个真实项目里,这个组合帮我省下了大量写 CSS 的时间。Tailwind 的响应式断点直接用md:、lg:前缀,根本不用写媒体查询。说实话,从传统 CSS 切到 Tailwind 是有学习曲线的,但一旦适应了,你会觉得以前写 class 名和样式绑定是纯纯的浪费时间。

3. 从零搭建 t3code 的完整实操

3.1 初始化项目与基础目录结构

用create-t3-app这个 CLI 工具初始化最省事。它会让你交互式选择要不要 TypeScript、Tailwind、tRPC、Prisma、NextAuth——全选上就行。这里有个细节:CLI 版本和社区模板不一样,create-t3-app生成的结构里已经自带了一个示例 Router 和示例数据库模型,我的建议是保留它们作为参考,先跑通了再删。

初始化完成后的目录结构大致长这样:

t3code/ src/ app/ (auth)/ (protected)/ api/ layout.tsx page.tsx components/ lib/ db.ts utils.ts server/ api/ routers/ post.ts user.ts root.ts auth.ts db.ts trpc/ server.ts client.ts react.tsx prisma/ schema.prisma

很多新手看到这么多文件会慌,实际上核心只有三条链路:数据库链路(Prisma + db.ts)、类型安全 API 链路(tRPC server/client/router)、鉴权链路(NextAuth + session)。先把这三条链路理解透,剩下的组件和样式都是锦上添花。

3.2 数据库模型设计:从用户表开始的完整范例

Prisma schema 是整个数据结构的单一事实来源。t3code 里我设计了一个相对典型的小型业务模型:用户(User)、会话(Session)、账户(Account,用于 OAuth 关联)、帖子(Post)。前三个模型是 NextAuth 官方推荐的必填项,Post 则是我加进去的示例业务表。

// prisma/schema.prisma datasource db { provider = "sqlite" url = env("DATABASE_URL") } generator client { provider = "prisma-client-js" } 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 Post { id String @id @default(cuid()) title String content String? published Boolean @default(false) author User? @relation(fields: [authorId], references: [id]) authorId String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }

这里有几个实操要点:

  • 主键用cuid()而不是自增Int。生产环境里数据库分库或合并数据库时,cuid 不会冲突,而且不用查RETURNING id,在 tRPC 里做嵌套创建和关联查询时体验极其舒服。
  • @updatedAt是 Prisma 自动管理的字段,写入时会自动更新,不用在业务代码里手动赋值。
  • Post.author 做成了可选关系(authorId String?),这样匿名浏览场景下帖子没有作者也能保存,写业务逻辑时少了很多空值判断的麻烦。

模型建好之后跑一遍迁移命令:

npx prisma db push npx prisma generate

db push适合开发阶段快速同步,正式上生产尽量用migrate dev生成迁移历史。我见过不少人拿db push怼到生产环境,结果数据库迁移记录一塌糊涂,回滚都没法回。

3.3 tRPC Router:类型安全 API 到底怎么写

先建一个基础 Router,注意 tRPC 的 v10/v11 写法略有差异,现在 create-t3-app 默认生成的是 v11 风格。核心步骤是先初始化 tRPC 实例,再注入 NextAuth 的 session 上下文。

// src/server/api/trpc.ts import { initTRPC, TRPCError } from '@trpc/server'; import { type CreateNextContextOptions } from '@trpc/server/adapters/next'; import { getServerAuthSession } from '@/server/auth'; import { db } from '@/server/db'; export const createTRPCContext = (opts: CreateNextContextOptions) => { const session = getServerAuthSession(opts); return { db, session, }; }; export const t = initTRPC.context<typeof createTRPCContext>().create(); 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: { session: { ...ctx.session, user: ctx.session.user }, }, }); });

看了这段代码你就明白 tRPC 的威力:procedures 是你的 API 端点,publicProcedure所有人可调用,protectedProcedure会自动检查登录状态,未登录直接抛 UNAUTHORIZED。类型层面,你在 procedure 的input里定义了什么,前端调用时就能拿到什么类型提示,永远不会出现字段拼错、类型对不上的情况。

写业务 Router 的实操如下:

// src/server/api/routers/post.ts import { z } from 'zod'; import { createTRPCRouter, publicProcedure, protectedProcedure } from '@/server/api/trpc'; export const postRouter = createTRPCRouter({ list: publicProcedure .input( z.object({ limit: z.number().min(1).max(50).default(10), orderBy: z.enum(['newest', 'oldest']).default('newest'), }) ) .query(async ({ ctx, input }) => { return ctx.db.post.findMany({ take: input.limit, orderBy: { createdAt: input.orderBy === 'newest' ? 'desc' : 'asc' }, }); }), create: protectedProcedure .input( z.object({ title: z.string().min(1).max(100), content: z.string().min(1).max(5000).optional(), }) ) .mutation(async ({ ctx, input }) => { return ctx.db.post.create({ data: { title: input.title, content: input.content ?? null, authorId: ctx.session.user.id, }, }); }), });

这里再唠叨一下 zod 的作用。tRPC 的 input 校验全靠 zod,它不只是给前端提供类型,还会在服务端运行时做真实的校验。也就是说,哪怕有攻击者绕过前端直接调用接口,非法参数也会在进入数据库之前被拦截。我在 t3code 里所有 Router 的输入都强制写了 zod schema,一个例外都没留。

前端调用侧的代码简单到令人发指:

// src/app/_components/post-create.tsx 'use client'; import { api } from '@/trpc/react'; export function PostCreate() { const utils = api.useUtils(); const createPost = api.post.create.useMutation({ onSuccess: () => { utils.post.list.invalidate(); }, }); const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { e.preventDefault(); const form = new FormData(e.currentTarget); createPost.mutate({ title: String(form.get('title')), content: String(form.get('content')), }); }; return ( <form onSubmit={handleSubmit}> <input name="title" placeholder="标题" required /> <textarea name="content" placeholder="内容" /> <button type="submit" disabled={createPost.isLoading}> {createPost.isLoading ? '发布中...' : '发布'} </button> </form> ); }

注意api.post.create.useMutation这个 Hook 的类型是完全从服务端 Router 推导过来的,前面 zod 里定义的title和content在这里会自动获得类型约束。调用成功之后用utils.post.list.invalidate()刷新列表,不用手写任何 refetch 逻辑,这是 t3code 里最爽的一个点。

3.4 NextAuth 鉴权接入的完整配置

鉴权这里我想多说一点,因为它是 T3 Stack 链路上最容易翻车的部分。NextAuth 现在改名叫 Auth.js,生态处于青黄不接的阶段,create-t3-app 生成的代码里用的还是 NextAuth v4 的 API。我建议跟着模板走,不要手动升级到 v5,否则一堆类型定义会出走样事故。

最简化的 auth 配置如下:

// src/server/auth.ts import NextAuth from 'next-auth'; import DiscordProvider from 'next-auth/providers/discord'; import { PrismaAdapter } from '@next-auth/prisma-adapter'; import { db } from '@/server/db'; export const { handlers, auth, signIn, signOut } = NextAuth({ adapter: PrismaAdapter(db), providers: [ DiscordProvider({ clientId: process.env.DISCORD_CLIENT_ID ?? '', clientSecret: process.env.DISCORD_CLIENT_SECRET ?? '', }), ], session: { strategy: 'database' }, });

注意两点:session.strategy必须显式设置为'database',否则 NextAuth 默认用 JWT strategy,在使用 PrismaAdapter 时会出现 session 无法写入数据库的怪问题。另外DISCORD_CLIENT_ID和DISCORD_CLIENT_SECRET这种环境变量在本地开发时放进.env文件,在 Vercel 部署时填到环境变量面板里,千万别提交到 Git 仓库。

为了支持邮箱密码登录(不依赖第三方 OAuth),需要扩展 PrismaAccount 模型中的 providerType 字段,这一块在 create-t3-app 生成的 schema 里已经帮你处理好了,不用额外改。第三方 OAuth 的好处是省掉整条密码哈希和重置链路,但如果你的产品面向企业内网,还是得加上 Credentials Provider 自己管密码。

4. 部署与性能调优实录

4.1 Vercel 部署的三件关键事

t3code 部署我首推 Vercel,因为 Next.js 和 Vercel 的兼容性本来就是原生级别,再加上 tRPC 走 HTTP 协议无特殊要求,基本能做到零配置部署。但有几个地方必须提前处理,否则你会遇到线上跑不起来或者数据库连接超限的问题。

首先是环境变量。DATABASE_URL、NEXTAUTH_SECRET、OAuth 的 clientId 和 clientSecret 都要在 Vercel 的 Project Settings 里独立配置。其中NEXTAUTH_SECRET建议用openssl rand -base64 32生成,切忌用固定字符串。这个 secret 是用来加密 session cookie 的,泄露了等于所有登录态都可以被伪造。

其次是 Prisma Client 的生成问题。Vercel 的构建环境是 Linux x64,但如果你在本地 Windows/Mac 上跑过prisma generate,生成的引擎二进制可能不匹配服务器平台。解决方法是把构建命令改成:

prisma generate && next build

我习惯在package.json的 build 脚本里写死,防止有人本地构建完直接把.next目录手动传到服务器上搞出跨平台崩溃。

最后是函数超时。Serverless 函数默认执行时长有限制,对于 tRPC 的 query 还好,如果某个 mutation 需要做大量数据库写入,建议把它拆成异步任务或者换用长期运行的 Node 服务。在 t3code 里我严格控制了单个 procedure 的耗时,数据库操作都保持在百毫秒级别,实测下来没有撞到超时。

4.2 数据库连接池与并发性能

SQLite 在本地开发很愉快,但部署到 Vercel 上就会出问题:Serverless 函数的冷启动和并发放大效应,会让 SQLite 的写冲突概率直线上升。这也是前面说的“生产前切 PostgreSQL”的真正原因。Prisma 连接 PostgreSQL 时,我强烈建议在 schema 里配置连接池参数:

datasource db { provider = "postgresql" url = env("DATABASE_URL") relationMode = "prisma" }

然后在连接串里加上 pool 参数,比如DATABASE_URL="postgresql://user:pass@host:5432/dbname?connection_limit=5&pool_timeout=10"。注意 Serverless 环境里每个函数实例都会维护自己的连接,池开得太大容易把数据库连接数打满,太小又会出现请求排队。我实测connection_limit=5在中小流量下比较稳。

另外 Prisma 操作 PostgreSQL 时有个容易忽略的问题:布尔字段要显式传true/false,不能只传字段名。很多人从 SQLite 切过来后不记得这个差异,导致查询结果莫名奇妙为空,debug 半天才发现是类型隐式转换的问题。

5. 常见问题与排查技巧实录

5.1 问题速查表

现象可能原因解决办法
tRPC 调用返回 UNAUTHORIZEDsession 未正确传给 tRPC Context检查createTRPCContext里是否调用了getServerAuthSession,且确保调用的 URL 走的是 App Router 的 route handler
Prisma 找不到数据库表还没执行迁移本地执行npx prisma db push或npx prisma migrate dev,上线前用prisma migrate deploy
NextAuth 登录后 session 为空session strategy 没设置成 database在 NextAuth 配置里显式写session: { strategy: 'database' }
部署到 Vercel 后 tRPC 404Route Handler 路径未匹配检查app/api/trpc/[trpc]/route.ts文件是否存在且导出 handler,create-t3-app 默认生成的路径是app/api/trpc/[trpc]/route.ts
类型报错:Router 没有导出 createCallertRPC 版本不一致统一用 create-t3-app 生成的 tRPC v11 API,不要手动混用 v10 文档写法
冷启动极慢导致接口超时Prisma Client 初始化过重确保在服务端统一复用globalThis缓存(模板里有 globalThis.prisma 的写法),避免每次新建连接

5.2 实操心得与避坑技巧

这几个月用 t3code 做了几个真实项目之后,总结几条常规文档里不会写的东西:

先说 tRPC 的invalidate()。很多人不知道它底层是怎么工作的——useUtils().xxx.invalidate()是精确失效,而router.invalidate()是粗暴的全量重刷。在列表页和详情页并存的项目里,全量刷新会带来大量无意义的重复请求。我建议尽可能用精确的invalidate并带上参数匹配,比如utils.post.list.invalidate({ filter: { published: true } })。这样刷新范围小,响应更快。

再说 NextAuth 和 tRPC 的联动问题。tRPC 的 Context 里拿到的 session 是从 cookie 反解出来的,如果你在同一个页面里同时用了服务端组件拉数据再在客户端组件里发起 tRPC mutation,前后端会话并不共享同一个内存对象。这不算 bug,但新手很容易误解。解决方案就是:数据读取尽量放在服务端组件里完成,需要更新数据的操作才走客户端 mutation,避免把整页逻辑都塞到客户端,省得出现 session 时有时无的幻觉。

还有 shadcn/ui 组件的暗黑模式。t3code 里我预留了next-themes实现主题切换,注意要把ThemeProvider挂在根布局的最外层,并且suppressHydrationWarning加到html标签上,否则刷新页面时会看到主题闪烁。这个闪烁问题的排查思路:Markup 中主题类在服务端渲染和客户端水合时不一致导致的,加suppressHydrationWarning后水合过程就不会因为属性不同而报警了。

最后关于环境变量组织:我在根目录放了.env.example文件,里面列了所有需要的环境变量和简单注释,谁 clone 项目下来复制一份.env就能跑。这个动作成本极低,但能省掉大量“你把这个字段发我一下”的沟通成本。放在团队项目里,等于给新同学一份最快的上手路径。

6. 最后再分享一个扩展方向

t3code 目前这个版本只做到了单库单应用的基础结构,但它的架构留足了扩展空间。我现在正在做的一件事,是把文件存储整合进来——比如用 S3 兼容的对象存储做图片上传,tRPC 里写一个 uploadUrl 生成预签名 URL 的 procedure,前端拿到 URL 之后直传对象存储。这样既绕开了 Serverless 函数体大小限制,又降低了流量成本。

我个人的体会是,像 t3code 这样的模板类项目,真正的价值不在代码本身,而在于它把“从零到一”的那段最痛苦的路程替你走完了。你后续做的每一个功能,都是在这个稳定基础之上长出来的。如果你也想搭一套属于自己的全栈模板,我的建议是先动手跑一遍 create-t3-app,然后按照自己的业务习惯去改目录、改命名、改封装方式,最终长出来的东西不一定还叫 t3code,但它一定是最顺手的那套脚手架。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进