☰
T3 Stack全栈TypeScript实战:tRPC、Prisma与NextAuth落地复盘
2026/10/28 5:18:00 网站建设 项目流程

上个月我给自己定了个小目标:别再看零散的教程,老老实实用一个完整项目把全栈 TypeScript 的整个流程过一遍。最后躺在仓库里的那个项目,就叫 t3code——基于 T3 Stack 搭建的代码片段分享平台,没有花哨的微服务、没为炫技硬上新技术,就是把认证、数据库建模、类型安全的 RPC 通信串成一条完整链路。今天这篇就把整个落地过程写下来:怎么选型、怎么定数据模型、tRPC 到底怎么用才顺手,以及那些文档里翻不到的坑。

如果你正在 Next.js 和一堆 orm、请求库的排列组合里纠结,或者单纯想知道 T3 Stack 是不是被吹过头了,这篇内容应该能让你少走几步弯路。

1. 项目选型与整体思路:T3 Stack 为什么适合 t3code

1.1 全栈 TypeScript 的共识变化

放在四五年前,我要搭一个带用户体系的 Web 应用,大概率是 React + Redux + Express + Sequelize 这么一套组合。前后端各写各的类型定义,接口文档靠手打,改一个字段要翻三个文件。那时候 TypeScript 虽然已经在用,但“全栈类型安全”这个概念还没成形,更不用说一套工具链直接帮你把类型从数据库一路推到页面组件里。

T3 Stack 出现的时机很有意思。它把 Next.js、TypeScript、Tailwind CSS 作为底座,再按需引入 tRPC、Prisma、NextAuth.js,核心思路就一句话:减少配置、减少重复代码、让类型成为唯一的真相来源。t3code 这个项目之所以选择 T3 Stack,不是因为流行,而是因为它天然适合“数据库模型 + 用户认证 + API 层 + 前端展示”这种经典全栈结构。

类比一下就是:过去你要自己买零件组装一台相机,镜头卡口不匹配、光圈联动杆旷量太大,都是自己想办法。T3 Stack 相当于给你一台机身和几支原厂镜头,接口是统一设计的,装上去就能用,剩下的事情是专心拍好照片。

1.2 create-t3-app:十分钟拉出可运行骨架

t3code 的项目骨架直接用 create-t3-app 生成的,命令很简单:

npm create t3-app@latest

交互式命令行会问你选哪些模块,我的选择是:

  • Next.js(App Router 模式)
  • TypeScript(默认开启 strict 模式)
  • Tailwind CSS
  • tRPC
  • Prisma(数据库用 SQLite 起步,后续要换 Postgres 也只是一行配置的事)
  • NextAuth.js(先接 GitHub Provider,后面再加邮箱登录)

之所以不用 Pages Router,是因为 App Router 的 Server Component 和处理服务端逻辑的方式更贴近我现在做项目的习惯。如果你的团队对 App Router 还不熟,用 Pages Router 也不是不行,T3 Stack 两种都支持,选哪种不纠结,能跑通就行。

骨架生成之后,目录结构长这样:

t3code/ ├── prisma/ │ └── schema.prisma ├── src/ │ ├── app/ │ │ ├── api/ │ │ │ └── trpc/ │ │ ├── layout.tsx │ │ └── page.tsx │ ├── components/ │ ├── server/ │ │ ├── api/ │ │ │ ├── routers/ │ │ │ └── root.ts │ │ └── auth.ts │ ├── styles/ │ │ └── globals.css │ └── trpc/ └── package.json

第一次看到这个结构的人可能会觉得目录有点多,但拆开看其实就是两层:app下面放页面和路由,server下面放业务逻辑和数据库操作,trpc目录负责连接前后端的类型安全的调用通道。这种分层方式我第一次用也觉得繁琐,等写完几个接口之后才理解它的价值——强制你把“页面展示”和“业务逻辑”分开,代码不会乱长。

2. 核心功能拆分与数据建模

2.1 先定功能边界,再动手写代码

t3code 的目标是做一个轻量的代码片段管理工具。功能不太多,但每一块都能把 T3 Stack 的某个典型场景覆盖到:

  • 用户注册、登录、退出,登录后才能创建片段,未登录只能浏览
  • 片段列表页支持按语言筛选、按标题搜索
  • 片段详情页展示代码内容、标签、点赞数量
  • 登录用户可以对片段点赞,点第二次取消点赞
  • 个人中心展示自己发布的片段列表

为什么选“代码片段管理”这个场景?因为它对实时性要求不高,不需要 WebSocket,不需要复杂的权限矩阵,但又能覆盖 CURD、关联查询、认证授权、搜索筛选这些最常见的全栈需求。对一个想验证技术栈是否好用的项目来说,这是最合适的复杂度。

2.2 Prisma 数据模型设计

数据模型是整个项目的基石。t3code 的 Prisma schema 我一开始设计得比较简单,后来在实现点赞功能时加了一个关系表。最终版本长这样:

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[] snippets CodeSnippet[] likes Like[] } model CodeSnippet { id String @id @default(cuid()) title String content String @db.Text language String @default("plaintext") summary String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt authorId String author User @relation(fields: [authorId], references: [id], onDelete: Cascade) tags Tag[] @relation("SnippetTags") likes Like[] } model Tag { id String @id @default(cuid()) name String @unique snippets CodeSnippet[] @relation("SnippetTags") } model Like { id String @id @default(cuid()) createdAt DateTime @default(now()) userId String snippetId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) snippet CodeSnippet @relation(fields: [snippetId], references: [id], onDelete: Cascade) @@unique([userId, snippetId]) }

几个设计细节值得说一下。

主键用cuid()而不是自增id。虽然 SQLite 自增主键性能更好,但 t3code 未来如果想做数据同步、或者把数据迁移到分布式数据库,cuid 不需要中心化协调就能生成唯一 ID。对于一个小项目来说这个理由可能有点“过度设计”,但我见过太多上线之后为换主键类型折腾到半夜的案例,宁可一开始多花两分钟。

User模型里的accounts、sessions字段不要自己造轮子,NextAuth.js 的 Prisma adapter 会自动使用这些关系。如果你手写User模型时把这些字段删了,后面接入 OAuth 的时候会报一堆奇怪的错误。

点赞表用了联合唯一索引@@unique([userId, snippetId]),这是数据库层面防止重复点赞的兜底。业务层再怎么加判断,都不如数据库约束可靠。

设计完 schema,执行迁移:

npx prisma migrate dev --name init

这条命令会创建数据库表,同时生成 Prisma Client。注意如果你之后改了 schema,这个命令要再跑一次,并且最好加上--name参数标注这次迁移改了什么。

3. 关键实现:认证流程与 tRPC 接口

3.1 NextAuth.js 接入,比你想象的更简单

t3code 的认证我选择了 GitHub Provider。接入步骤也就三步。

第一步,装依赖:

npm install next-auth @auth/prisma-adapter

第二步,在src/server/auth.ts里配置:

import NextAuth from "next-auth"; import GitHub from "next-auth/providers/github"; import { PrismaAdapter } from "@auth/prisma-adapter"; import { prisma } from "@/server/db"; export const authOptions = { adapter: PrismaAdapter(prisma), providers: [ GitHub({ clientId: process.env.GITHUB_CLIENT_ID!, clientSecret: process.env.GITHUB_CLIENT_SECRET!, }), ], callbacks: { session({ session, user }) { if (session.user) { session.user.id = user.id; } return session; }, }, session: { strategy: "jwt", }, } satisfies NextAuthOptions; export const { handlers, auth, signIn, signOut } = NextAuth(authOptions);

第三步,在src/app/api/auth/[...nextauth]/route.ts里导出路由处理器:

import { handlers } from "@/server/auth"; export const { GET, POST } = handlers;

到这里,/api/auth/signin、/api/auth/callback这些端点就自动有了。页面里想拿到登录态,优先使用useSessionhook:

import { useSession, signIn, signOut } from "next-auth/react"; function Header() { const { data: session } = useSession(); return session?.user ? ( <div> <span>{session.user.name}</span> <button onClick={() => signOut()}>退出</button> </div> ) : ( <button onClick={() => signIn("github")}>登录</button> ); }

有个坑必须提醒:GitHub OAuth 应用的回调地址,一定要填完整的http://localhost:3000/api/auth/callback/github。我见过不少人只填了http://localhost:3000,点登录后永远停在错误页。

另一个容易踩到的坑是session.strategy。如果你用 Prisma adapter 但不显式设置strategy为jwt,NextAuth.js 会默认使用数据库存 session。这本身没问题,但回传的session.user.id需要通过 session callback 手写进去。不写这段回调的话,前端拿到的session.user里没有id字段,后面做“当前用户是否点过赞”这种判断就无从下手。

3.2 tRPC 路由拆分:不把所有逻辑塞进一个文件

t3code 的接口层全部走 tRPC,没有手写 REST API。这可能是一些人觉得“重”的地方,但用顺手之后确实会提高效率。

src/server/api/root.ts里注册顶层路由:

import { createTRPCRouter } from "@/server/api/trpc"; import { snippetRouter } from "./routers/snippet"; import { userRouter } from "./routers/user"; export const appRouter = createTRPCRouter({ snippet: snippetRouter, user: userRouter, }); export type AppRouter = typeof appRouter;

snippet路由负责代码片段相关的所有操作。列表查询是publicProcedure,创建、更新、删除是protectedProcedure:

import { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "@/server/api/trpc"; export const snippetRouter = createTRPCRouter({ list: publicProcedure .input( z.object({ language: z.string().optional(), search: z.string().optional(), take: z.number().min(1).max(50).default(20), }) ) .query(async ({ ctx, input }) => { return ctx.prisma.codeSnippet.findMany({ where: { ...(input.language ? { language: input.language } : {}), ...(input.search ? { OR: [ { title: { contains: input.search } }, { summary: { contains: input.search } }, ], } : {}), }, include: { author: { select: { name: true, image: true } }, tags: true, _count: { select: { likes: true } }, }, orderBy: { createdAt: "desc" }, take: input.take, }); }), detail: publicProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { return ctx.prisma.codeSnippet.findUnique({ where: { id: input.id }, include: { author: { select: { name: true, image: true } }, tags: true, likes: true, _count: { select: { likes: true } }, }, }); }), create: protectedProcedure .input( z.object({ title: z.string().min(1, "标题不能为空").max(100), content: z.string().min(1, "代码不能为空"), language: z.string().max(50).default("plaintext"), summary: z.string().max(300).optional(), tags: z.array(z.string().max(30)).max(5).optional(), }) ) .mutation(async ({ ctx, input }) => { const { title, content, language, summary, tags } = input; const authorId = ctx.session.user.id; return ctx.prisma.codeSnippet.create({ data: { title, content, language, summary, authorId, tags: { connectOrCreate: (tags ?? []).map((name) => ({ where: { name }, create: { name }, })), }, }, }); }), });

注意create这个 mutation 里是怎么处理标签的:用connectOrCreate把“要找的标签找出来连上,找不到就新建”这个语义一次性表达完。这块逻辑如果用 REST + 手写 SQL,至少要多写十行还容易出错,但在 Prisma 里就是一个嵌套关系搞定。

Zod 校验在这里不只是为了验证参数,还提供了类型推断。前端调用create时,编辑器会把title、content这些字段的类型直接提示出来,传错类型编译期就报错。

3.3 前端怎么调用 tRPC 接口

t3code 前端没手写fetch,也没有 axios,全部通过 tRPC 的 React hook 调用。

在页面组件里用:

"use client"; import { api } from "@/trpc/react"; export default function SnippetList({ language }: { language?: string }) { const { data, isLoading, error } = api.snippet.list.useQuery({ language, take: 20, }); if (isLoading) return <p className="py-8 text-center text-gray-400">加载中...</p>; if (error) return <p className="py-8 text-center text-red-400">加载失败</p>; return ( <div className="grid gap-4"> {data?.map((snippet) => ( <article key={snippet.id} className="border rounded-lg p-4"> <h2 className="font-semibold">{snippet.title}</h2> <div className="flex gap-2 text-sm text-gray-500"> <span>{snippet.language}</span> <span>{snippet._count.likes} 赞</span> </div> </article> ))} </div> ); }

调用 mutation 更直接:

import { api } from "@/trpc/react"; const utils = api.useUtils(); const createSnippet = api.snippet.create.useMutation({ onSuccess: () => { // 创建成功后自动刷新列表 utils.snippet.list.invalidate(); }, });

utils.snippet.list.invalidate()这行是 tRPC 的杀手锏。它做的事情是:让当前页面上所有正在使用snippet.list的组件自动重新请求数据,不需要你手动维护一个全局状态库,也不需要发射自定义事件。

我第一次看到这个 API 时有点怀疑它会引发无限循环,实际用下来并没有——它只在 mutation 成功后触发一次重新获取。相比以前写 Redux 时手动 dispatch action 更新状态、再处理更新失败的回滚逻辑,这个开发体验提升是实实在在的。

3.4 点赞功能的并发控制

点赞接口单独拿出来讲一下,因为它涉及一个很多教程不会提的细节。

toggleLike: protectedProcedure .input(z.object({ snippetId: z.string() })) .mutation(async ({ ctx, input }) => { const userId = ctx.session.user.id; const existing = await ctx.prisma.like.findUnique({ where: { userId_snippetId: { userId, snippetId: input.snippetId, }, }, }); if (existing) { await ctx.prisma.like.delete({ where: { id: existing.id } }); return { liked: false }; } await ctx.prisma.like.create({ data: { userId, snippetId: input.snippetId }, }); return { liked: true }; }),

这里有一个一目了然的隐患:如果用户以极快的速度连续点击两次点赞按钮,两个请求同时进来,都查到existing为 null,然后都想执行create,第二次就会撞上数据库的联合唯一索引,直接抛出一个 Prisma 特有的错误。

碰到这种情况怎么处理?我用了两种方式兜底:

第一种是在前端做防抖,按钮点击后disabled,等 mutation 返回再恢复。这是最简单的措施,能挡掉 99% 的误操作。

第二种是在后端比较粗暴但有效:把create包在 try/catch 里,遇到P2002(Prisma 唯一约束冲突错误码)就忽略,然后重新查一遍数据库,把最终状态返回给前端。

try { await ctx.prisma.like.create({ data: { userId, snippetId: input.snippetId }, }); } catch (e) { // P2002: unique constraint failed // 说明并发请求里另一个请求已经创建成功了,直接当作已点赞处理 }

虽然方案不够优雅,但它保证了接口在并发场景下不崩。如果以后并发量上来,可以改用原生 SQL 的INSERT ... ON CONFLICT或者事务串行化,但那是后话。

4. 前端渲染与页面交互实现

4.1 Server Component 和 Client Component 的边界划分

T3 Stack 的默认模板是 App Router,这意味着页面默认是 Server Component。t3code 在写页面时反复确认了一件事:哪些组件必须标"use client",哪些可以保持服务端渲染。

规则其实很简单——只有需要用到交互状态、浏览器 API 或 tRPC hook 的组件才标记为 client。比如片段列表页本身是服务端渲染的,标题、摘要、作者信息都能在服务端查完直接输出 HTML,加载速度快。只有点赞按钮、搜索框这一类需要实时交互的地方,才抽出来单独做成客户端组件:

// SnippetCard 是 server component import { LikeButton } from "./LikeButton"; export function SnippetCard({ snippet }: { snippet: Snippet }) { return ( <div className="rounded-lg border p-4"> <h3>{snippet.title}</h3> <pre className="max-h-40 overflow-auto text-sm">{snippet.content}</pre> {/* 只有按钮部分是 client component */} <LikeButton snippetId={snippet.id} initialLiked={snippet.likedByMe} /> </div> ); }

这种拆分方式比“整个页面挂 use client”好在哪里?最直接的好处是首屏渲染不需要执行大量 JavaScript,搜索引擎和低端手机用户都受益。另一个好处是组件边界清楚,SnippetCard可以不依赖任何客户端状态,单独做测试也很方便。

4.2 Tailwind 排版与代码块样式

t3code 的界面用 Tailwind CSS 写样式,没引入组件库,避免那些库的默认主题把项目带偏。

代码块的高亮,我用的是react-syntax-highlighter。这里有个经验值得说:它的 Prism 风格包体积比较大,动态加载能砍掉一部分没用的语言包。做法是懒加载:

import dynamic from "next/dynamic"; const CodeBlock = dynamic(() => import("./CodeBlock"), { ssr: false, loading: () => <div className="bg-gray-100 rounded-md h-40 animate-pulse" />, });

ssr: false是因为react-syntax-highlighter内部用了浏览器 API,服务端渲染会报错。第一次加载时会出现几毫秒的占位动画,视觉上不会让用户感觉到明显卡顿。

代码块的容器样式我踩了一个小坑:overflow-x-auto必须加在浏览器上,不然移动端窄屏下代码会撑破布局。Tailwind 写法是className="overflow-x-auto text-sm rounded-lg bg-slate-900 p-4 text-slate-50",背景色用深色,字体用等宽字体。如果想让代码可复制,我加了一个右上角的复制按钮,点击后用navigator.clipboard.writeText写入剪贴板。这个 API 在非 HTTPS 环境下不可用,开发环境用 localhost 没问题,部署到生产环境记得配 HTTPS。

4.3 页面级数据流:从 URL 参数到查询条件

t3code 的列表页支持按语言筛选、按标题搜索,这里我用了一个比较朴素但不笨的方案:把筛选条件直接编码到 URL query 参数里。

"use client"; import { useRouter, useSearchParams } from "next/navigation"; import { api } from "@/trpc/react"; export function SnippetListPage() { const router = useRouter(); const searchParams = useSearchParams(); const language = searchParams.get("language") ?? undefined; const search = searchParams.get("search") ?? undefined; const { data, isLoading } = api.snippet.list.useQuery({ language, search, take: 20 }); const setFilter = (key: string, value: string | undefined) => { const params = new URLSearchParams(searchParams.toString()); if (value) { params.set(key, value); } else { params.delete(key); } router.push(`?${params.toString()}`); }; return ( <div> <input defaultValue={search} onChange={(e) => setFilter("search", e.target.value)} placeholder="搜索代码片段..." className="border rounded px-3 py-2 w-full" /> <select defaultValue={language} onChange={(e) => setFilter("language", e.target.value || undefined)} > <option value="">全部语言</option> <option value="typescript">TypeScript</option> <option value="python">Python</option> <option value="go">Go</option> </select> {isLoading ? ( <p>加载中...</p> ) : ( <div className="mt-4 space-y-4"> {data?.map((snippet) => ( <SnippetCard key={snippet.id} snippet={snippet} /> ))} </div> )} </div> ); }

把筛选条件放 URL 的好处是:用户刷新页面不会丢失状态,分享链接给同事对方看到同样的筛选结果。很多初学者习惯把状态放在useState里,页面一刷新就回到初始状态,体验很差。URL 才是前端应用最被低估的“状态管理工具”。

这个方案的缺点也有一个:每次输入关键词都会触发router.push导致页面跳转,如果输入内容过长会觉得卡。解决办法是给搜索框加一个 debounce,比如useDeferredValue或者手动 setTimeout,这里就不展开讲了。

5. 实战踩坑与调试实录

5.1 Prisma 迁移生成的客户端版本不对

我在开发中改过一次CodeSnippet模型,给content字段加了@db.Text属性。改完之后执行npx prisma migrate dev --name update_content_type,随手写了个查询想确认新字段是否生效,结果 Prisma Client 报错说content字段不存在——虽然数据库里已经改好了。

原因很简单:Prisma Client 会根据schema.prisma生成类型定义,但你正在运行的 Node.js 进程里用的还是旧版本。解决方式是执行完迁移后,重启开发服务器。如果你是用tsx watch跑开发服务,它默认不会监听node_modules/.prisma目录,所以不会自动重启,必须手动来。

这个坑在 T3 Stack 里出现频率极高,因为它的dev脚本是next dev,不会自动检测 Prisma Client 变更。最省心的做法是每次改完 schema 后执行npx prisma generate并重启 dev server。

5.2 tRPC 校验错误被吞的问题

有一次我在页面上提交片段时发现,无论怎么填,接口都返回同一个错:Unexpected end of JSON input。排查了半天,最后发现是我在 Zod schema 里给content设置了z.string().min(1),但前端传的字段名是code,而后端读的是content。两边默默对不上,tRPC 通过 JSON 序列化时报了这么个误导性极强的错误。

这个问题的根源是前后端字段命名不一致,但让我印象更深的并非这个命名问题,而是 tRPC 本身的一个特点:当校验失败时,错误信息会按特定格式返回给前端,如果你没做特殊处理,@tanstack/react-query的默认错误提示并不直观。

现在我的习惯是:每个 mutation 调用都写一个onError回调,把error.message直接展示在表单上方。这样至少能看到是从哪一步开始挂的,而不是一脸懵地看着一个白屏。

5.3 NextAuth 登录一直回调失败

这个坑我印象非常深刻。t3code 在第一次接 NextAuth 时,本地登录可以,部署到服务器之后,点击 GitHub 登录,地址栏跳到了api/auth/callback/github,然后就停在错误页,不跳回去了。

检查了服务器日志,发现 GitHub 那边返回的state值对不上。最后定位到原因:服务器上的NEXTAUTH_URL没配置。NextAuth.js 在NEXTAUTH_URL缺失时会根据请求头里的Host自动推断,但如果你用了反向代理,推断出来的地址就可能不对。

解决办法非常简单,在.env里显式写上:

NEXTAUTH_URL=https://your-domain.com

还有一次我以为是NEXTAUTH_SECRET的问题,实际上那个环境变量是控制 HTTPSession 加密的,跟 callback 地址没关系。这两个环境变量经常被混淆,写配置的时候建议把名字打全,不要省。

5.4 页面路由的 404

t3code 有一个页面用到了动态路由app/snippets/[id]/page.tsx。写法是:

export default async function Page({ params }: { params: { id: string } }) { const snippet = await getSnippetById(params.id); if (!snippet) { notFound(); } return <SnippetDetail snippet={snippet} />; }

按理说逻辑没问题,但我发现直接访问/snippets/abc的时候,永远返回 404。查了很久才意识到,本地开发模式下notFound()会渲染 Next.js 官方的 404 页面,但如果你在服务器端渲染时抛了异常而不是调用notFound(),比如 Prisma 查询抛错而我又没 catch,那 Next.js 也会给你一个 404。

关键点在于:检查一下环境变量NODE_ENV是不是production。我那次其实是开发模式下数据库连接串配错了,导致 Prisma 连接失败,抛出来的错误被 Next.js 错误边界截获,最终呈现为 404,而不是原本的 500。这个错误提示很有迷惑性,排查时要优先看服务端日志,而不是盯着页面空转。

5.5 常见问题与解决速查表

我把开发 t3code 过程中遇到的高频问题整理成一张表,遇到的概率从高到低排列,方便你直接对照排查。

现象可能原因解决办法
tRPC 调用后无响应,页面卡住网络请求挂了,或前后端路径配置不对用浏览器 Network 面板看请求状态码;检查trpc/createContext是否有异步逻辑没返回
Prisma Client 报字段不存在改完 schema 后没重启 dev server执行npx prisma generate,重启服务
NextAuth 登录后不跳转NEXTAUTH_URL没设或设错在环境变量中显式配置完整 URL
GitHub 回调 403OAuth 应用回调地址不对检查 GitHub Settings 里的 callback URL
点赞后页面数据不变mutation 成功了但没触发重新查询调用utils.snippet.list.invalidate()
页面刷新后筛选状态丢失筛选条件存在组件 state 里改用 URL query 参数存储筛选条件
代码块在移动端溢出容器没设置overflow-x-auto给代码块外层容器加overflow-x-auto
部署后页面白屏环境变量缺失或跨域问题先看浏览器 console 和服务器日志,再检查环境变量

6. 部署与后续扩展方向

6.1 部署时要注意的几件事

t3code 我是部署在 Vercel 上的,过程很顺,但有几个细节需要提前处理好。

第一是数据库。本地用 SQLite 很方便,但 Vercel 的 serverless 函数是无状态的,不能直接用本地文件数据库。我换成了 Postgres,用的是 Vercel Postgres,Prisma 的datasource配置改一下:

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

这个切换几乎没有改业务代码,因为 Prisma 对数据库的差异屏蔽得比较好,底层 SQL 语句自动适配,这是当初选它而不是直接写 SQL 的一个重要收获。

第二是 Prisma Client 在 Vercel 构建时的缓存问题。建议在package.json的postinstall脚本里加上:

"postinstall": "prisma generate"

确保每次部署时都会重新生成 Prisma Client,而不是沿用构建缓存里的旧版本。

第三是环境变量。DATABASE_URL、GITHUB_CLIENT_ID、GITHUB_CLIENT_SECRET、NEXTAUTH_SECRET这四项是必须的,少一个都会在运行时出问题。Vercel 的 Dashboard 里把环境变量配好之后,记得点一下“Redeploy”,否则新配置不会生效。

6.2 t3code 还可以怎么扩展

当前 t3code 算是一个“功能完整但规模不大”的样板项目。如果你也想拿它练手,我建议从这些方向继续扩展:

  • 给代码片段增加编辑和删除功能,这会用到updateProcedure和deleteProcedure,思路和create一样,重点是权限校验:只能操作自己创建的片段。
  • 用 Prisma 的分页复杂查询优化列表页。目前是take加skip的简略写法,数据量过万之后建议改用更高效的分页策略。
  • 增加“复制到剪贴板”的成功反馈动画,这个小交互能显著提升产品质感,同时也可以加一个toast通知库练习一下全局状态管理。
  • 把代码片段的收藏功能改成“收藏夹 + 标签管理”,这是从 CRUD 到产品化的一个典型演进路径。

我个人的习惯是:每个项目收尾时回头看,哪一块代码写的自己都想吐槽,下一轮就优先练那个方向。t3code 让我意识到 tRPC 在中小型全栈项目里真能省掉不少胶水代码,我也在看它对于更大的项目边界在哪里。

如果你正考虑在下一个项目里引入 T3 Stack,我的建议是可以先从一个像 t3code 这样的小项目开始,别一上来就排兵布阵。跑通一次完整流程,你自然就知道这套技术栈哪些地方顺手、哪些地方需要自己找补。全栈工程的乐趣,很大一部分在于把这些工具链打磨得趁手,而一个真实项目就是最好的磨刀石。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询