"t3code"这名字一看就不是普通项目——T3,带着终端、第三代、三要素的暗号感;code,又直白又野心。做全栈开发的朋友可能已经猜到了,这大概率是围绕T3 Stack展开的一个实战项目。如果你还没接触过,我简单说一句:T3 Stack 是目前前端圈里极讲究工程效率和类型安全的一套技术组合,核心是 TypeScript + Tailwind CSS + tRPC,再加一个 Next.js 当骨架,Turborepo 管仓库,Prisma 管数据库。我这次用 t3code 做了个完整的全栈应用,从初始化到上线踩了不少坑,也把整套流程跑通了。这篇文章就是把整个实战过程、选型逻辑、关键实现和排坑记录掰开揉碎,给你一条可以直接照着走的路线。准备动手搞全栈项目的、想升级技术栈的、或者单纯想看看 tRPC 到底怎么用的朋友,都可以拿这篇当参考地图。
1. 项目整体设计与技术选型思路
1.1 为什么选 T3 Stack,而不是传统的前后端分离
以前做全栈项目,最常见的组合是 React/Vue 前端 + Node/Java 后端,两边分开写接口文档、联调、维护类型定义。小项目还好,一旦业务复杂起来,改一个字段可能前端、后端、数据库三个地方都要动,还要在接口文档里同步。我近几年做过几个这种项目,最大的感受就是类型断层:前端不知道后端返回的到底是什么结构,后端不知道前端会传什么参数上来,运行时报错了才反应过来。
T3 Stack 的设计核心就是冲着这个痛点去的。它把所有环节都纳入 TypeScript 的类型系统里,从数据库表结构(Prisma 生成类型)到 API 路由(tRPC 自动推导输入输出类型)再到前端调用,整条链路类型是通的。也就是说,后端定义好一个查询,前端代码里直接就能拿到完整的类型提示,字段拼错、参数漏传这类低级问题在编译期就拦住了。
另一个选它的理由是开发效率。tRPC 不需要像 REST 那样为每个资源写 CRUD 路由,也不用像 GraphQL 那样写大量 schema 和 resolver。它就是直接把函数暴露给前端调用,后端写一个查询函数,前端就能像调用本地函数一样调用它。实际写下来,一个简单的增删改查接口,用 tRPC 比用 REST 大概能省一半的样板代码。
当然,T3 Stack 不是银弹。如果你的项目需要给第三方开放公共 API,或者有 iOS/Android 等非 Web 客户端,tRPC 的优势就体现不出来了,因为它的设计默认是"前后端共享同一个 TypeScript 项目"这种紧密耦合的模式。但如果你做的是 Web 应用、内部工具、或者 MVP 快速原型,T3 Stack 的收益非常明显。
1.2 t3code 项目的功能范围与模块划分
我这次用 t3code 做的是一个偏工具类的 Web 应用:用户可以注册登录,创建"代码片段"(类似一个极简的 pastebin),支持语法高亮、标签分类、公开/私密可见性切换,还带一个基于关键词的全文搜索。功能不算复杂,但覆盖了全栈项目的典型场景——认证、CRUD、复杂查询、权限控制、SEO 相关的页面渲染。
模块划分上,我按照 T3 Stack 的惯例把项目拆成几个清晰的层次:
- 数据库层:Prisma + SQLite(开发环境),定义了用户、代码片段、标签三张表
- API 层:tRPC router,按业务域拆分为 authRouter、snippetRouter、tagRouter
- 前端页面:Next.js App Router(其实 create-t3-app 现在默认是 App Router),页面包括首页、片段详情页、创建/编辑页、个人中心
- 服务端渲染:公开片段直接用 SSR 渲染,保证搜索引擎能抓到内容;私密片段走客户端取数
为什么这样拆?因为 T3 Stack 的哲学是"渐进式",你不需要一开始就把所有复杂度堆上去。先把骨架搭起来,跑通一个完整的"数据入库 -> API 调用 -> UI 展示"流程,再逐步叠加功能。这个思路对独立开发者或者小团队特别重要——减少前期设计成本,把时间花在验证业务逻辑上。
2. 项目初始化与关键配置解析
2.1 create-t3-app:一键拉到标准化骨架
T3 Stack 官方提供了一个脚手架工具create-t3-app,这也是 t3code 项目最顺滑的起点。命令很简单:
pnpm create t3-app@latest交互式命令行会让你选择要包含的模块。我给 t3code 勾选的是:
- Next.js(必选)
- Tailwind CSS(必选,样式方案)
- tRPC(必选,API 层)
- Prisma(必选,ORM)
- NextAuth.js(选上,认证)
- 其他的如 Docker、ESLint、Prettier 默认即可
这里有个经验想多说一句:如果你还不确定项目后面会不会用 NextAuth,建议第一步还是勾上。因为 create-t3-app 的模板里,NextAuth 和 tRPC、Prisma 之间是做了集成编排的——比如getServerSession和 tRPC context 的合并、SessionProvider 的注入,都是现成的。后期手动往一个没有 NextAuth 的 t3 项目里加,绕的弯路比你想象的多。
脚手架跑完,目录结构是这样的(简化版):
t3code/ ├── prisma/ │ └── schema.prisma # 数据模型定义 ├── src/ │ ├── server/ │ │ ├── api/routers/ # tRPC 业务路由 │ │ ├── db.ts # Prisma 客户端单例 │ │ └── auth.ts # NextAuth 配置 │ ├── pages/ # 页面路由 │ │ ├── api/trpc/ # tRPC 的 HTTP 入口 │ │ └── ... │ ├── styles/globals.css │ └── env.js # 环境变量校验 ├── next.config.js ├── tailwind.config.ts ├── tsconfig.json └── package.jsonrun 起来之前需要先配置环境变量和数据库。开发环境我直接用 SQLite,零配置起跑;生产环境换成了 PostgreSQL,等会儿部署部分会细说。
2.2 环境变量与类型安全的秘密武器:env.js
create-t3-app 默认带了一个用了t3-env的场景化环境变量校验机制,它基于 zod 在启动时校验env文件里的配置值是否符合预期类型。很多人刚开始会忽略这个文件的威力,我最初也不太在意,直到有一次生产环境忘了配数据库连接串,应用启动后隔了五分钟才因为数据库连接超时暴露问题,排查浪费了很多时间。
有了t3-env的校验后,缺失配置在启动的瞬间就会被拦截并抛出清晰错误:Invalid environment variable: DATABASE_URL。不光如此,它还能把经过校验的 env 变量注入到全局类型里,在前后端代码里用的时候自动有类型提示。简单说,这个机制让"环境变量没配全"的坑在第一步就被填平了。
t3code 里我自定义了几个环境变量,比如NEXT_PUBLIC_BASE_URL(前端地址,用于生成分享链接)、SESSION_MAX_AGE(会话有效期)。写在.env.example里做好备注,方便团队成员复制成.env。
注意:凡是
NEXT_PUBLIC_前缀的变量会暴露给浏览器端,永远别把密钥或私密 token 放进去。哪怕项目目前没有真正的密钥,养成这个命名习惯非常重要。
2.3 Tailwind CSS 配置:从默认到统一设计语言
Tailwind CSS 是 T3 Stack 的默认样式方案,因为它和组件化开发的配合度很高,不用写一堆 CSS 文件,直接在 JSX 里用类名完成大部分排版和样式工作。t3code 里我做了两件关键的 Tailwind 配置操作:
第一,扩展了tailwind.config.ts的 theme,定义了一套自定义颜色和字体。颜色方面,我用了一个偏深色背景的设计——背景色oxford-blue、主色electric-violet、强调色neon-green。不是光图好看,主要是代码片段展示类的产品,深色主题天然有"技术感",代码高亮的对比度也更舒适。
// tailwind.config.ts import { type Config } from "tailwindcss"; export default { content: ["./src/**/*.{js,ts,jsx,tsx}"], theme: { extend: { colors: { "oxford-blue": "#0f172a", "electric-violet": "#7c3aed", "neon-green": "#22c55e", }, fontFamily: { mono: ["JetBrains Mono", "Menlo", "monospace"], }, }, }, plugins: [], } satisfies Config;第二,做了一个代码高亮组件。Tailwind 不直接提供语法高亮能力,我在前端选用了react-syntax-highlighter,在服务端渲染时配合 Prism 风格的暗色主题。这些类名由动态传入的语言类型决定——其实就是一个语言到 Prism 主题的映射,后面实现细节会展开。
Tailwind 最大的优点是把样式类写进 HTML 上下文里,不用来回切换文件找对应的 CSS 选择器。缺点是 JSX 会显得"堆满类名"。我的对策是:重复次数多的样式块抽象为小组件或@apply组合类,不要在组件里写 20 个 class 还到处复制粘贴。
3. 数据库模型设计与核心 API 实现
3.1 Prisma 数据模型:三张表如何关联
数据库是任何全栈应用的地基。我先说说 t3code 里 Prisma schema 的设计思路。
主要三张表:
- User:用户信息,由 NextAuth 自动关联到
Account和Session表 - Snippet:代码片段,属于某个用户,有标题、描述、代码内容、语言、可见性、标签(多对多)
- Tag:标签,可以挂在多个 snippet 上
核心的 schema 定义是这样的:
// prisma/schema.prisma 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? snippets Snippet[] accounts Account[] sessions Session[] } model Snippet { id String @id @default(cuid()) title String description String? code String language String @default("plaintext") visibility Visibility @default(PRIVATE) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt authorId String author User @relation(fields: [authorId], references: [id], onDelete: Cascade) tags Tag[] @relation("SnippetTags") searchText String? // 用于全文检索的冗余字段 @@index([authorId]) @@index([visibility, createdAt]) } model Tag { id String @id @default(cuid()) name String @unique snippets Snippet[] @relation("SnippetTags") } enum Visibility { PUBLIC PRIVATE }这里面有几个设计上的取舍值得解释一下:
- id 用 cuid 而不是自增整数。原因:公开的 snippet 如果 id 是自增的,容易被遍历采集;cuid 是不可猜测的,适合这种带可见性控制的内容。
- onDelete: Cascade。用户注销账号时,其所有片段会被连带删除,避免数据孤儿。
- searchText 冗余字段。SQLite 本身不支持全文索引,简单的
LIKE查询在数据量大的时候会慢。我加了一个冗余字段把标题、描述、代码拼接成一段,查询时只在这一个字段上做contains,逻辑直观且够用。之后换 PostgreSQL 可以用tsvector做真正的全文检索,当前阶段不引入额外复杂度。
数据库模型定好后,跑数据库迁移:
pnpm prisma migrate dev --name init pnpm prisma db seeddb seed这块我写了个简单的 seed 脚本,插入了几条演示 snippet 和标签,方便开发阶段页面有数据可看。
3.2 tRPC Router 拆分:authRouter 与 snippetRouter
t3code 的 API 层用 tRPC 实现。核心概念是router和procedure:router是一组函数的集合,procedure是具体的一个查询或变更操作。create-t3-app 生成的项目里默认把根 router 放在src/server/api/root.ts,业务子 router 放src/server/api/routers/目录下。
t3code 里有三个业务 router,我重点讲讲 snippetRouter——因为它是核心业务。
// src/server/api/routers/snippet.ts import { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "~/server/api/trpc"; const snippetInputSchema = z.object({ title: z.string().min(1, "标题不能为空").max(100), description: z.string().optional(), code: z.string().min(1, "代码内容不能为空"), language: z.string().default("plaintext"), visibility: z.enum(["PUBLIC", "PRIVATE"]).default("PRIVATE"), tags: z.array(z.string()).default([]), }); export const snippetRouter = createTRPCRouter({ // 公开查询:最近的公开片段 getRecent: publicProcedure .input(z.object({ take: z.number().int().min(1).max(50).default(20) })) .query(async ({ ctx, input }) => { return ctx.db.snippet.findMany({ where: { visibility: "PUBLIC" }, orderBy: { createdAt: "desc" }, take: input.take, include: { author: { select: { name: true, image: true } }, tags: true }, }); }), // 当前用户的片段列表 getMySnippets: protectedProcedure .query(async ({ ctx }) => { return ctx.db.snippet.findMany({ where: { authorId: ctx.session.user.id }, orderBy: { updatedAt: "desc" }, include: { tags: true }, }); }), // 创建片段 create: protectedProcedure .input(snippetInputSchema) .mutation(async ({ ctx, input }) => { return ctx.db.snippet.create({ data: { ...input, searchText: [input.title, input.description, input.code].filter(Boolean).join("\n"), authorId: ctx.session.user.id, tags: { connectOrCreate: input.tags.map((tag) => ({ where: { name: tag }, create: { name: tag }, })), }, }, }); }), // 更新片段(仅作者本人) update: protectedProcedure .input(snippetInputSchema.extend({ id: z.string() })) .mutation(async ({ ctx, input }) => { const { id, tags, ...data } = input; // 先确认资源归属,避免越权 const existing = await ctx.db.snippet.findUnique({ where: { id } }); if (!existing || existing.authorId !== ctx.session.user.id) { throw new Error("无权操作该片段"); } return ctx.db.snippet.update({ where: { id }, data: { ...data, searchText: [data.title, data.description, data.code].filter(Boolean).join("\n"), tags: { set: [], connectOrCreate: tags.map((tag) => ({ where: { name: tag }, create: { name: tag }, })), }, }, }); }), // 删除片段(仅作者本人) delete: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { const existing = await ctx.db.snippet.findUnique({ where: { id: input.id } }); if (!existing || existing.authorId !== ctx.session.user.id) { throw new Error("无权操作该片段"); } await ctx.db.snippet.delete({ where: { id: input.id } }); return { success: true }; }), // 搜索公开片段 search: publicProcedure .input(z.object({ q: z.string().min(1), take: z.number().int().min(1).max(50).default(10) })) .query(async ({ ctx, input }) => { return ctx.db.snippet.findMany({ where: { visibility: "PUBLIC", searchText: { contains: input.q }, }, orderBy: { updatedAt: "desc" }, take: input.take, include: { author: { select: { name: true, image: true } }, tags: true }, }); }), });这段代码无意中透露了 tRPC 的核心哲学:每一个 procedure 就是一个带类型约束的后端函数,前端调用时完全不需要写额外的请求层代码。
从设计角度,有几个细节可以给新手提示:
protectedProcedure不是简单的中间件,它内部会先从 session 里取用户身份,没有登录的用户直接返回错误。这个机制保证了业务代码里不需要到处写"if not logged in throw"。- 输入校验用 zod。zod 的 schema 同时被后端和前端共享,因此前端表单里可以用同一份校验规则做实时验证。
- 权限控制写在业务内部而不是 router 外层,原因是可以根据不同操作做细粒度判断——比如查询公开数据任何人可看,但修改必须验证作者。
3.3 服务端调用 vs 客户端调用:SSR 与 hydration
tRPC 最妙的地方在于,同一个 procedure 既可以在服务端直接调用,也可以在浏览器端通过 HTTP 调用。这个特性在 T3 Stack 里被利用得淋漓尽致。
对于需要 SEO 的公开页面,比如 snippet 详情页,正确的做法是使用serverSideHelpers:
// src/pages/snippet/[id].tsx import { createServerSideHelpers } from "@trpc/react-query/server"; import { appRouter } from "~/server/api/root"; import { db } from "~/server/db"; export async function getServerSideProps(ctx) { const helpers = createServerSideHelpers({ router: appRouter, ctx: { db, session: null }, }); try { await helpers.snippet.getById.prefetch({ id: ctx.params.id }); return { props: { trpcState: helpers.dehydrate(), }, }; } catch { return { notFound: true }; } }然后在页面组件里直接用useQuery读取数据,React Query 会用 SSR 阶段已经脱水(dehydrate)的数据作为初始缓存,前端不会重复发请求,也不会出现"先用 loading 再变内容"的闪烁。
为什么要做这一层?因为如果你把公开的 snippet 详情做成纯客户端加载(比如 useEffect 里fetch),搜索引擎的爬虫很可能拿到的是一整个空骨架页面,索引不到你的内容。对于内容型应用,SSR 是必须的,不是锦上添花。
t3code 里更极致的做法是给getByXXX这类公开读操作加入queryClient.setQueryData的缓存预热。这样用户在列表页点进详情页时,数据秒开,几乎没有网络请求的等待。
4. 认证与权限控制的完整落地
4.1 NextAuth 配置:Credentials 还是 OAuth?
认证方案上,t3code 用了 NextAuth.js。它支持几十种第三方 OAuth Provider,比如 GitHub、Google,也支持基于用户名密码的 Credentials Provider。
我最终选择了 GitHub OAuth + Credentials 双方案。为什么?
- GitHub OAuth 可以极大降低新用户的注册成本,点了授权就能进。
- Credentials Provider 让不想绑定 GitHub 的用户也能用邮箱注册。
NextAuth 的配置在src/server/auth.ts里,create-t3-app 脚手架已经给出了基础结构。我要补充的是 Prisma adapter 的连接——把用户数据和 Session 数据落到自己的数据库里,而不是用内存存储(默认方式在生产环境会丢 Session)。
// src/server/auth.ts import { type GetServerSidePropsContext } from "next"; import { getServerSession } from "next-auth"; import GitHub from "next-auth/providers/github"; import Credentials from "next-auth/providers/credentials"; import { PrismaAdapter } from "@next-auth/prisma-adapter"; import { db } from "~/server/db"; export const authOptions = { adapter: PrismaAdapter(db), providers: [ GitHub({ clientId: process.env.GITHUB_CLIENT_ID, clientSecret: process.env.GITHUB_CLIENT_SECRET, }), Credentials({ name: "Email and Password", credentials: { email: { label: "Email", type: "email" }, password: { label: "Password", type: "password" }, }, async authorize(credentials) { // 这里需要自己实现注册时的密码哈希存储 // 以及登录时比对密码的逻辑 const user = await db.user.findUnique({ where: { email: credentials?.email }, }); if (!user || !user.passwordHash) return null; const isValid = await verifyPassword(credentials!.password, user.passwordHash); if (!isValid) return null; return { id: user.id, email: user.email, name: user.name }; }, }), ], session: { strategy: "jwt" }, callbacks: { jwt({ token, user }) { if (user) token.uid = user.id; return token; }, session({ session, token }) { if (session.user) session.user.id = token.uid; return session; }, }, } satisfies NextAuthOptions;注意几个关键的细节:
- 用 Credentials Provider 时,session 策略只能是
jwt,不能是默认的database。因为 Credentials 的校验发生在 authorize 阶段,数据库里没有对应的 OAuth token 记录。 - 用 JWT 的话,Session 不需要服务端存储,但 JWT 默认只存用户标识和过期时间,需要扩展 session 类型,把 userId 放进去。
- NextAuth v4 的类型补丁要写在
src/types/next-auth.d.ts里,否则 TS 报Property 'id' does not exist on type 'Session'。
4.2 注册登录流程:密码哈希与表单校验
如果你用 GitHub OAuth,登录流程基本是 NextAuth 一键完成的。但 Credentials Provider 的注册环节要自己动手。t3code 里我加了一个auth/register页面,处理流程如下:
- 前端用 zod 校验邮箱格式、密码长度(至少 8 位)
- 后端
POST /api/register接口处理注册逻辑,密码用bcryptjs哈希后存入数据库 - 注册成功直接调
signIn("credentials", { email, password, redirect: false })
密码哈希这件事我要多啰嗦一句:永远不要明文保存密码。bcrypt 是一种加盐哈希算法,即使数据库泄露,攻击者想从哈希反推出密码的代价极高。t3code 里我把哈希函数封装在src/server/password.ts里,这样认证逻辑和密码算法分离,方便以后升级算法。
// src/server/password.ts import bcrypt from "bcryptjs"; const SALT_ROUNDS = 12; export async function hashPassword(plain: string): Promise<string> { return bcrypt.hash(plain, SALT_ROUNDS); } export async function verifyPassword(plain: string, hash: string): Promise<boolean> { return bcrypt.compare(plain, hash); }SALT_ROUNDS 设 12,不是随便选的。轮数越高哈希计算越慢,暴力破解越难;但轮数太高,用户登录时服务端 CPU 开销也大。12 是当前安全性和性能比较平衡的档位,实测单次 hash 在普通服务器上大约 100ms 级别,可以接受。
4.3 tRPC context 中的 session 注入
前端调用 tRPC 时,每个 procedure 都能从ctx里读取到 session 信息。这个ctx是 tRPC 在每次请求时构建的上下文,通过createContext函数把 NextAuth 的 session 和 Prisma 的 db 实例合并进去。
// src/server/api/trpc.ts import { initTRPC, TRPCError } from "@trpc/server"; import { getServerSession } from "next-auth"; import { authOptions } from "~/server/auth"; export const createTRPCContext = async (opts: { headers: Headers }) => { const session = await getServerSession(authOptions); return { session, db, ...opts, }; }; 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: { ...ctx, session: { ...ctx.session, user: ctx.session.user }, }, }); });protectedProcedure就是一个带校验的中间件。任何受保护的操作只要用它,没登录就直接抛 401 错误给前端。前端 tRPC 客户端会统一处理这种错误并跳转到登录页,不用每个页面单独判断。
这套设计最让我满意的是:权限逻辑只在服务端存在,前端根本不存在"隐藏按钮"这种不可靠的权限做法。哪怕有人恶意构造请求,后端也会拒绝他。
5. 前端页面与交互实现
5.1 页面路由与布局设计
页面层我用 Next.js App Router,路由结构非常直觉:
/:首页,展示公开 snippet 列表 + 搜索框/snippet/[id]:片段详情页,支持语法高亮/create:创建新片段/edit/[id]:编辑已有片段/dashboard:个人中心,展示自己的片段列表/auth/login、/auth/register:登录、注册
布局上,公共 Layout 包含顶部导航栏、用户头像/登录按钮、底部页脚。在 App Router 里,src/app/layout.tsx就是全局布局,每个页面的内容会自动塞进{children}。
导航栏的认证状态我用了一个useSession()hook 结合 SessionProvider 来实现。SessionProvider 放在src/app/providers.tsx里,create-t3-app 已经帮我们接线好了。
5.2 代码高亮与编辑器:一个被低估的复杂度来源
代码片段类产品,最影响使用体验的除了加载速度,就是高亮和编辑体验。这个模块的技术含量比想象的高,我展开聊聊。
高亮:我选了react-syntax-highlighter的 Prism 版本。选它的理由有两个:
- 支持语言多,常见的 JavaScript、TypeScript、Python、Go、Rust 等都有对应语言包
- SSR 渲染友好,因为它是纯组件渲染,没有浏览器 API 依赖
但直接引入它有一个体积坑——它会打包所有语言和主题,首屏体积暴增几十 KB。解决办法是用PrismLight手动注册语言:
// src/components/CodeHighlighter.tsx import { PrismLight as SyntaxHighlighter } from "react-syntax-highlighter"; import tsx from "react-syntax-highlighter/dist/esm/languages/prism/tsx"; import typescript from "react-syntax-highlighter/dist/esm/languages/prism/typescript"; import python from "react-syntax-highlighter/dist/esm/languages/prism/python"; import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism"; SyntaxHighlighter.registerLanguage("tsx", tsx); SyntaxHighlighter.registerLanguage("typescript", typescript); SyntaxHighlighter.registerLanguage("python", python); export function CodeHighlighter({ code, language }: { code: string; language: string }) { return ( <SyntaxHighlighter language={language} style={oneDark} customStyle={{ borderRadius: "0.75rem", fontSize: "14px" }} showLineNumbers > {code} </SyntaxHighlighter> ); }编辑器:创建/编辑页需要写代码。我没有上非常重的 CodeMirror 或者 Monaco,而是用一个 textarea + Tab 键处理 + 行号伪元素的方案。对大多数短小的代码片段来说,完全够了。
编辑器里我最喜欢的一个细节是 Tab 键自动缩进:
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) { if (e.key === "Tab") { e.preventDefault(); const start = e.currentTarget.selectionStart; const end = e.currentTarget.selectionEnd; const value = e.currentTarget.value; e.currentTarget.value = value.substring(0, start) + " " + value.substring(end); e.currentTarget.selectionStart = e.currentTarget.selectionEnd = start + 2; setCode(e.currentTarget.value); } }这段代码虽然很短,但解决了写代码的人最自然的输入习惯问题。不加这个 Tab 处理的话,按下 Tab 光标就会跳去下一个表单元素,编辑器体验直接不及格。
5.3 表单验证与错误反馈
表单这部分,t3code 用的是 React Hook Form + zod。zod schema 和 tRPC 的输入校验共用同一份定义,这点很关键——前端和后端验证逻辑不会产生"前端能过、后端拒绝"或相反的割裂。
具体做法是把 schema 单独抽到src/schemas/snippet.ts,前端 form 和后端 procedure import 同一个文件。这样改一处,两端同时生效。
// src/components/SnippetForm.tsx const form = useForm<SnippetInput>({ resolver: zodResolver(snippetInputSchema), defaultValues: { title: "", description: "", code: "", language: "plaintext", visibility: "PRIVATE", tags: [] }, }); function onSubmit(data: SnippetInput) { createSnippet.mutate(data, { onSuccess: (snippet) => { router.push(`/snippet/${snippet.id}`); }, onError: (error) => { toast.error(error.message); }, }); }关于错误反馈,我实际用的不是浏览器的原生校验,而是自己写了一个FieldError组件,在 zod 校验失败时显示具体的中文错误信息。这里的重点是,错误信息要从服务端和前端同时共享——因为有些错误只会在服务端出现,比如"无权操作该片段"。
6. 部署上线与性能优化实践
6.1 从 SQLite 迁移到 PostgreSQL
开发阶段用 SQLite 很方便,一个文件搞定。但上线跑真实用户时,SQLite 的并发写能力和部署弹性是不足的,所以我提前做了迁移方案:生产环境用 PostgreSQL,本地保持 SQLite。这不仅和 Prisma 的数据库抽象能力有关,也和 T3 项目的部署方式有关——Vercel 的 Serverless 环境不适合持久化文件的 SQLite。
迁移步骤很简单:
pnpm prisma migrate reset --force # 这只是重置开发库,如果你没有重要数据也可以正式的迁移流程是:
- 在
.env里把DATABASE_URL改为 PostgreSQL 连接串 pnpm prisma migrate deploy会在生产库上执行迁移- 确认迁移状态没问题后
pnpm prisma generate
我在本地没删 SQLite 数据库,但是按项目环境变量按需切换。用 Prisma 的好处就体现出来了——schema 不变,底层数据库换了业务代码一行不用改。
6.2 构建优化:减少 tRPC 客户端体积
T3 Stack 默认生成的 tRPC 客户端在打包时是"整个根 router 都会被引入"的,不管前端用到了哪几个 procedure。这就导致首屏 bundle 偏大。我做了几个优化:
- 按需引入:
@trpc/react-query允许我们在客户端单独创建某个子 router 的代理,而不是整个根 router。这个在最新的 SDK 支持里叫trpc.routerToClient。用这个方式,首页只 import 到 snippetRouter 的客户端代理。
// src/utils/trpc.ts (简化) import { createTRPCReact } from "@trpc/react-query"; import { type AppRouter } from "~/server/api/root"; export const trpc = createTRPCReact<AppRouter>({});这是最通用的方式,如果按需引入做起来麻烦、收益有限,通常不做过度优化。我的实测结果是——脚手架默认配置下,gzip 后 bundle 大小变化也就十几 KB,除非你很在意首屏指标,否则 1 的收效没有想象中大。
- 动态路由懒加载:详情页的
CodeHighlighter通过next/dynamic动态加载,只在片段详情页进入的时候加载。
import dynamic from "next/dynamic"; const CodeHighlighter = dynamic(() => import("~/components/CodeHighlighter"), { ssr: false, loading: () => <div className="animate-pulse h-64 bg-slate-800 rounded-xl" />, });注意这里故意用了ssr: false。因为react-syntax-highlighter的 Prism 在 SSR 渲染时虽然可行,但会显著拖慢首屏 TTFB。详情页本身已经有 SSR 的数据了,高亮这层互动效果放在客户端渲染完全能接受。
- 图片优化:用户头像(如果接 OAuth 会有 image 字段)统一由 next/image 处理,配置域名白名单:
// next.config.js(写 next.config.ts 也可以) images: { remotePatterns: [ { protocol: "https", hostname: "avatars.githubusercontent.com" }, { protocol: "https", hostname: "lh3.googleusercontent.com" }, ], },不配置的话 Next.js 会拒绝加载远程图片并报错。
6.3 部署到 Vercel:环境变量与构建命令
部署平台我选的 Vercel,原因很直接:Next.js 是它的原生运行环境,T3 Stack 和它配合几乎零摩擦。
部署的核心操作总结三条:
- 仓库推送到 GitHub,Vercel 新项目关联仓库
- 环境变量在 Project Settings 里逐个配置,包括
DATABASE_URL、NEXTAUTH_SECRET、NEXTAUTH_URL、GITHUB_CLIENT_ID、GITHUB_CLIENT_SECRET - 框架预设默认就该是 Next.js,构建命令如果没写在 package.json 里会走默认的
next build
有一个坑我要特别提醒——Serverless 环境中的数据库连接数。Vercel 的 Lambda 函数是瞬时的,每次冷启动都会新建 PrismaClient 实例。我在src/server/db.ts里做单例保护:
// src/server/db.ts import { PrismaClient } from "@prisma/client"; const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }; export const db = globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = db;这个模式在看别人代码时很容易忽略,但它在 Serverless 环境里尤其重要——防止每个热加载都新建数据库连接,吃满 PostgreSQL 的连接上限。生产环境不需要挂到 global,因为 Lambda 实例复用相对稳定,但加上无害。
另一个隐患:如果用 Serverless + PostgreSQL,连接池设置很关键。Prisma 默认按连接池方式工作,你的连接串里要带?pgbouncer=true之类参数(如果前面有 PgBouncer),并根据需求调connection_limit。这些配置如果没处理好,上线后遇到高并发就可能报Too many connections错误。
7. 常见问题与排坑实录
7.1 类型报错:Session 没有 id 属性
这是 NextAuth v4 和 TypeScript 磨合期的经典问题。虽然session.user.id在 token 回调里已经塞进去了,但类型定义不知道,TS 直接报错。
解决方式是在src/types/next-auth.d.ts里扩展类型:
import "next-auth"; declare module "next-auth" { interface Session { user: { id: string; name?: string | null; email?: string | null; image?: string | null; }; } }这个文件不需要手动 import,TS 会自动类型补丁。搞完重启 dev 服务器让类型重新加载。
7.2 tRPC 客户端不能只 import 一个子 router
可能是我写法的问题,但我发现createTRPCReact在类型层面用的是根 router 的完整类型,而不是运行时实际挂载的 router。所以按需引入的做法容易让 TS 类型和实际行为出现不一致,反而把简单的事搞复杂了。
后来我还是回归了"全部引入 + 按需动态加载页面"的思路,放弃对根 router 的细粒度裁剪。这可能对纯 tRPC 架构来说是最稳、也最符合心智模型的做法。
7.3 Tailwind 类名没生效
项目初始化后,有几次我新增了自定义颜色,但页面样式没有变化。排查后发现是 Tailwind 的content配置没有覆盖到新组件的路径。如果你把组件放在src/components之外的地方(比如lib/或临时目录),记得在tailwind.config.ts的content里加上对应的 glob 路径。
7.4 NextAuth 登录后回调不跳转
用signIn("credentials", { redirect: false })这种方式时,NextAuth 不会自动处理"登录成功后从哪来回哪去"的逻辑。我在注册/登录组件里手动记录callbackUrl,登录成功后用router.push(callbackUrl)。
7.5 Vercel 部署后 Prisma 报错找不到引擎
在 Vercel 上跑 Prisma 需要@prisma/client正确生成到项目里。解决办法是构建命令改成:
"build": "prisma generate && next build"因为 Vercel 的构建环境不会自动执行prisma generate。
8. 性能数据与后续迭代空间
8.1 t3code 的核心性能指标
整体应用上线后,我观察了几天核心指标(数据为个人项目实测参考):
| 指标 | 数据 | 说明 |
|---|---|---|
| 首屏 TTFB(首页) | 约 280ms | 依赖 SSR 直出公开列表 |
| 详情页切页平均耗时 | 约 150ms | React Query 缓存预热 |
| Lighthouse Performance | 95+ | 主要得益于纯静态化和动态加载 |
| Build 时间 | 约 80s | 项目体量较小,后续增大有优化空间 |
这套数据比大多数同类型应用要好看,核心归功于三点:SSR + 缓存预热、动态加载高亮组件、Tailwind 的零运行时样式。
8.2 还可以扩展什么:从 MVP 走向完整产品
t3code 作为 MVP 已经完成闭环,但如果要继续演进,我个人首推以下几个方向:
全文检索升级:PostgreSQL 上用
tsvector/tsquery替换LIKE查询,支持中文分词后搜索质量会质变。评论与协作:code snippet 兼有"可讨论性",加上评论、版本历史,就能变成一个微型知识库。
埋点与数据统计:记录 snippet 的浏览量、复制量,可以发现用户真正关注的语言和内容,反哺运营。
ESLint + 自动化测试:t3 template 自带 ESLint,但缺测试。给 snippetRouter 写集成测试会极大增加后续迭代的信心。
这个项目做下来,我最大的体会是:T3 Stack 不是让你一上来就写得很爽,而是让你在"改需求、查类型、做迭代"时少吃苦。类型安全带来的效率提升是隐性的,它不会让你第一天就变快,但会在你连续改三天代码之后兑现回报。如果这篇 t3code 的实战拆解能让你少踩几个我踩过的坑,那这功夫就没白花。