
t3code 是我去年年底开始持续打磨的一个全栈项目模板核心思路非常简单用 T3 Stack 那一套技术组合Next.js、TypeScript、tRPC、Prisma、TailwindCSS做出一个不仅能跑通 hello world 的脚手架而是能把登录鉴权、数据建模、接口调用、前端渲染这些真实业务刚需一次性整合好的“种子工程”。如果你正准备从零开始做一个中后台应用、SaaS 产品或者个人的 side project但又不想花大量时间在技术选型和基建搭建上这个项目应该能给你一个相当顺手的基础。我在这个项目里花了不少时间处理各种边界情况和类型问题最终沉淀出来的这套路子不管是给团队用还是自己用都非常舒服。这篇文章我打算把 t3code 的设计思路、核心实现和踩坑经验完整拆开来讲包含关键代码和可直接复用的配置。内容会比较长但基本覆盖了从一个空文件夹到可部署全栈应用的全过程建议收藏后边看边操作。1. 项目概述t3code 到底要解决什么问题1.1 为什么放着现成的 create-t3-app 不用还要自己造轮子很多人听到 T3 Stack第一反应就是create-t3-app脚手架已经够好用了为什么还要再搞一个 t3code。坦诚说create-t3-app 确实是一个非常好的起步工具它把 Next.js、TypeScript、tRPC、Prisma、TailwindCSS、NextAuth 这些框架一次性整合好十分钟就能拉起一个项目。但真正用它做过业务的人都会发现一个问题脚手架给的是骨架不是业务范式。我拿 create-t3-app 搭过三个项目每次都要重复处理同样的事情用户体系虽然有 NextAuth但角色权限、组织成员、邀请链接全都得自己写tRPC 路由虽然能跑通但错误码规范、查询缓存策略、服务端返回结构都没有统一约定Prisma schema 虽然是空的但具体到多对多关系、软删除、审计日志光靠文档里的示例根本不够。这些重复劳动让我意识到我需要的不只是一个“能跑”的模板而是一个“能直接拿来起步做业务”的工程框架。t3code 就是在这样的背景下产生的。说点更直白的理解create-t3-app 是毛坯房t3code 是带水电改造和基本硬装的精装房。精装房当然不那么自由但它是按大多数人的居住习惯来设计的你拿了就能住不需要从刷墙开始折腾。1.2 t3code 的定位与适用场景t3code 不是一个追求“全家桶大而全”的框架它更强调“业务可直接起步”。项目目录里我已经预置好了完整的用户注册登录流程、基于角色的访问控制、组织与成员管理、统一 API 响应格式、错误处理中间件以及一套经过验证的 Prisma 数据模型。哪些场景我推荐直接用 t3code首先是内部工具类项目这类项目对界面要求不算高但对数据录入和查询效率有要求其次是 SaaS 产品的 MVP 版本用户系统、订阅状态、应用配置都是现成的模板还有一类是个人博客或内容管理系统这类项目更看重后台管理的顺手程度。反过来如果你的需求很特殊比如公司内部有统一 UI 规范要求或者你是做纯展示型官网那 t3code 未必合适自己拉一个干净的项目反而更灵活。t3code 的技术底座在这里简要列一下Next.js 14App Router、TypeScript 严格模式、tRPC v11、Prisma 5、TailwindCSS 3、NextAuth 4。这个组合的妙处在于整条链路都是 TypeScript 类型贯穿的从数据库到 API 再到前端组件几乎没有类型断层。这一点我后面会专门展开讲。2. 技术选型拆解为什么选中这条全栈链路2.1 Next.js TypeScript类型安全的源头t3code 里最底层的选择其实是 Next.js。N 选项在 T3 领域很特殊因为它即可以纯静态渲染也可以完全动态渲染还能两者混合。我现在用的 App Router 模式RSCReact Server Components天然把服务端渲染和客户端交互割开配合在 t3code 中大量使用的server-only依赖标记可以把绝不应该出现在前端的服务端代码隔离得干干净净。TypeScript 严格模式在这个项目里不是摆设。我刚搭 t3code 的时候TypeScript 配置里strict直接拉满noUncheckedIndexedAccess也开着。这意味着对数组索引读取时TypeScript 会反复提醒你“这可能是 undefined”。很多开发者嫌这个配置烦但实际业务里空数组取第一个元素恰恰是最常见的 bug 来源之一。这块我专门设置了tsconfig的路径别名/指向根目录的src。所有 import 里都不会出现../../../../这种让人头大的相对路径。配合/server/db、/server/api这样的目录划分代码可读性和可维护性都好了不少。2.2 tRPC不写 REST 也不写 GraphQL照样全栈联动tRPC 可以说是 t3code 里最核心的通信方案。传统的 REST 接口方案需要你在后端写路由、写控制器、写类型定义再到前端写对应的 request 封装和一个又一个 interfaceGraphQL 虽然类型也好但光 schema 和 resolver 就能劝退一批人。tRPC 的思路是把服务端过程直接暴露给前端调用类型自动推导改一个服务端返回结构前端类型立刻跟着变。在 t3code 里我定义了一个标准流程任何一次前端调用从前端进入 tRPC 路由经过middleware的鉴权和校验再进到具体处理的procedure。整个过程类型安全是“免费附赠”的所有输入输出都由 TypeScript 严格约束。这种做法的好处在重构时体现得最明显改个字段名前端如果漏改编译直接飘红而不是等运行时请求报错才反应过来。当然tRPC 也有它的适用范围。如果是纯面向第三方的开放 API我建议你还是单独做一个 REST 或 GraphQL 网关层但 t3code 面向的是自己的前端应用tRPC 的效率和调试体验远好于传统方案。2.3 Prisma TailwindCSS数据层和 UI 层的“确定性”选 Prisma是因为它把数据库表结构变成了一个schema.prisma文件改表结构、做迁移Migration、生成类型全部是确定性操作。我甚至在 t3code 里加入了prisma migrate reset的一键脚本用于开发环境的数据库重置。这样做的好处是一个新人加入团队跑一条命令就能得到一个一致的数据库结构和基础数据。TailwindCSS 相对争议多一些但我认为它在项目起步阶段是最省心的。t3code 里我预置了简洁的设计规范间距、颜色、字体、圆角、阴影都抽象成了设计变量。这意味着业务页面开发时不需要临时去纠结“这个蓝应该用什么色号”直接用primary、danger、muted这类语义化 class 就可以了。虽然初始配置需要花点时间但后面每个页面都能长得像同一个产品这个体验非常值。2.4 与传统“全栈拼装”方案的对比拿最常见的“前端 React 后端 Express/Spring 数据库”拼装方案对比其实可以直接用表格感受一下核心差异对比维度传统拼装方案t3code (T3 Stack)类型同步前后端各维护一套类型容易漂移全链路 TypeScript 单一来源API 联调需要定义协议、处理 CORS、写文档过程直接调用类型驱动数据建模通常要面向应用代码设计表结构Playground 思路Schema 即事实权限接入需要单独集成框架NextAuth 中间件统一处理前端 UI依靠组件库或手写 CSSTailwind 语义化设计变量上线成本需要准备 API 服务独立部署可直接内嵌于 Next.js 部署当然我不是说传统拼装方案一无是处如果团队里本来就有人力维护一个成熟的后端服务或者有现存接口需要对接那用传统方案完全没问题。但如果你要快速起一个新项目特别是团队规模不大时t3code 这种整链路打通的方案确实能省掉大量的协调成本。3. 核心实现与实操从零搭起 t3code3.1 初始化项目与目录规划用 create-t3-app 可以很快速地把基础框架拉起来但 t3code 的目录结构和默认脚手架是有区别的。我不用 NextAuth 自带的示例配置而是按业务模块重新梳理了一版。这是 t3code 的目录规划示例src/ ├── components/ │ ├── ui/ # 通用 UI 组件按钮、输入框、卡片 │ ├── layout/ # 布局组件侧边栏、顶栏、主容器 │ └── features/ # 业务模块组件登录表单、成员列表 ├── server/ │ ├── api/ │ │ ├── root.ts # tRPC 根路由汇总 │ │ ├── router/ │ │ │ ├── auth.ts │ │ │ ├── user.ts │ │ │ └── org.ts │ │ └── trpc.ts # tRPC 初始化上下文 │ ├── db/ │ │ └── schema.prisma # Prisma 数据模型 │ └── auth/ │ └── index.ts # NextAuth 配置封装 ├── lib/ │ ├── validators/ # zod 输入校验 │ └── utils.ts # 通用工具函数 └── app/ ├── (auth)/ # 登录注册路由组 ├── (dashboard)/ # 需要登录才能访问的页面 └── api/trpc/[trpc]/route.ts这里有个关键原则服务端代码和客户端代码必须从物理目录上分离开。src/server下所有的模块都加了import server-only这行代码是 Next.js 提供的编译期保护如果有人不小心把服务端模块引入到了客户端组件里构建直接报错。目录隔离 编译保护双保险才能真正防止数据库连接跑到浏览器里。3.2 定义数据模型从用户到组织的完整体系数据模型是 t3code 的基石。我设计 Prisma schema 时并不打算只放一个简单的 User 表而是把真实业务里反复出现的关系都纳入进来用户、组织、成员关系、会话账号、登录凭证。下面这一段是核心模型的精简版// prisma/schema.prisma generator client { provider prisma-client-js } datasource db { provider sqlite // 本地开发快速起步可平滑迁移 PostgreSQL url env(DATABASE_URL) } model User { id String id default(cuid()) name String? email String? unique emailVerified DateTime? image String? passwordHash String? // 密码登录的哈希结果 createdAt DateTime default(now()) updatedAt DateTime updatedAt accounts Account[] sessions Session[] memberships OrgMember[] } model Org { id String id default(cuid()) name String slug String unique createdAt DateTime default(now()) updatedAt DateTime updatedAt members OrgMember[] } model OrgMember { id String id default(cuid()) orgId String userId String role String default(member) // owner | admin | member createdAt DateTime default(now()) user User relation(fields: [userId], references: [id], onDelete: Cascade) org Org relation(fields: [orgId], references: [id], onDelete: Cascade) unique([orgId, userId]) } model Account { id String id default(cuid()) userId String type String provider String providerAccountId String refreshToken String? map(refresh_token) accessToken String? map(access_token) expiresAt Int? map(expires_at) user User relation(fields: [userId], references: [id], onDelete: Cascade) unique([provider, providerAccountId]) } model Session { id String id default(cuid()) sessionToken String unique userId String expires DateTime user User relation(fields: [userId], references: [id], onDelete: Cascade) }这里有一个很容易踩的坑很多人一开始会把OrgMember.role设计成枚举enum但在真实业务里角色体系经常要扩展比如加“审计员”“观察者”如果预先是 DB 枚举每次改角色都必须生成迁移。t3code 里我故意用 String 类型再在业务层用 Zod 做运行时的枚举校验。这样角色列表灵活性高而且校验逻辑集中在一个地方后续要改成“角色 权限点”的模型也不用动数据库。3.3 核心 tRPC 路由实现类型安全如何贯穿全栈tRPC 路由是 t3code 的前后端桥梁。下面这一段代码是创建用户组织、并给用户分配角色典型的业务场景// src/server/api/trpc.ts import { initTRPC, TRPCError } from trpc/server; import superjson from superjson; import { ZodError } from zod; import type { createContext } from ../context; const t initTRPC.contexttypeof createContext().create({ transformer: superjson, errorFormatter({ shape, error }) { return { ...shape, data: { ...shape.data, zodError: error.cause instanceof ZodError ? error.cause.flatten() : null, }, }; }, }); export const router t.router; 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, user: ctx.session.user } }); });这段代码里的protectedProcedure非常关键。它从 tRPC 上下文里读取当前登录会话如果用户未登录直接抛UNAUTHORIZED错误。所有需要登录的接口只要把它换成protectedProcedure鉴权逻辑就自动生效了。另外errorFormatter里对 Zod 校验错误做了结构化处理把字段级别的错误信息返回给前端前端就能直接在对应输入框下展示具体的校验失败原因。下面看一个具体的路由实现功能是“用户创建组织并成为 owner”// src/server/api/router/org.ts import { z } from zod; import { protectedProcedure, router } from ../trpc; export const orgRouter router({ create: protectedProcedure .input( z.object({ name: z.string().min(2, 组织名称至少2个字符), slug: z .string() .regex(/^[a-z0-9-]$/, 仅允许小写字母、数字、连字符), }) ) .mutation(async ({ ctx, input }) { const org await ctx.db.org.create({ data: { name: input.name, slug: input.slug, members: { create: { userId: ctx.user.id, role: owner, }, }, }, }); return { id: org.id, slug: org.slug, name: org.name, }; }), });注意这里ctx.user并不是直接从数据库查出来的完整用户记录而是Session.user的子集包含id、email、name。要不要在上下文里带上完整用户信息要看你的业务复杂度如果每次请求都需要查一遍用户表性能会打折但如果只在需要时才去数据库查就能省下不少开销。t3code 的策略是上下文只放会话中的基础信息路由内部再去按需查询详情。3.4 NextAuth 会话接入与路由守卫登录认证这块我同时支持了邮箱密码登录和 OAuth 登录两种方式。密码登录用的并不是 NextAuth 默认支持的 Provider而是自己实现了一个 Credentials Provider用户提交邮箱密码后服务端用 bcrypt 比对哈希成功则签发会话。先看 NextAuth 配置的核心片段// src/server/auth/index.ts import NextAuth from next-auth; import Credentials from next-auth/providers/credentials; import { compare } from bcryptjs; import { db } from ../db; export const { handlers, signIn, signOut, auth } NextAuth({ providers: [ Credentials({ credentials: { email: { label: 邮箱, type: email }, password: { label: 密码, type: password }, }, authorize: async (credentials) { const email credentials?.email as string; const password credentials?.password as string; const user await db.user.findUnique({ where: { email } }); if (!user?.passwordHash) return null; const valid await compare(password, user.passwordHash); if (!valid) return null; return { id: user.id, email: user.email, name: user.name }; }, }), ], callbacks: { session({ session, token }) { if (session.user) (session.user as any).id token.sub; return session; }, jwt({ token, user }) { if (user) token.sub user.id; return token; }, }, pages: { signIn: /login, }, session: { strategy: jwt, }, });需要注意的一点是我用的是 JWT 策略的 session而不是数据库 session。这样部署无状态、水平扩展很友好。如果你选了database策略那 Prisma 的 Session 模型就必须和 NextAuth 的字段完全对齐不然会有各种奇怪报错。前端路由守卫我直接在布局层做了。因为服务端可以拿到 session 状态因此在app/(dashboard)/layout.tsx里做一个校验逻辑就能实现所有子路由的访问控制这比每个页面都复制粘贴“判断登录状态”的代码干净得多// src/app/(dashboard)/layout.tsx import { redirect } from next/navigation; import { auth } from /server/auth; export default async function DashboardLayout({ children, }: { children: React.ReactNode; }) { const session await auth(); if (!session?.user) { redirect(/login); } return div classNamemin-h-screen{children}/div; }这个服务端布局层的定向逻辑是很直观的访问任何在(dashboard)路由组下的页面如果没登录就跳到登录页。有了这层拦截前端组件里就不用再掺入各种hasSession判断了。4. 常见问题与排查技巧实录4.1 tRPC 服务端类型无法同步到前端这是新接触 tRPC 时最常见的困惑。症状是你在服务端路由里新增了一个protectedProcedure前端调用时 TypeScript 完全看不到这个新方法甚至直接提示类型不存在。第一次遇到这个问题我一度以为是 tRPC 的魔法失效了。排查思路其实很简单因为 tRPC 的类型是编译期从服务端推导过来的前端项目如果没重新编译自然不会出现新接口。在 Next.js 开发模式下通常改完路由会自动热更新但偶尔因为缓存或者并行运行导致类型没有及时刷新。t3code 里我加了tsx typecheck作为强制检查每次启动开发服务器之前先跑一次完整类型检查。如果你发现类型确实没刷新最直接的命令是npx tsc --noEmit如果类型检查通过但前端还是看不到那就观察一下tsconfig里include是不是覆盖到了src/server目录。如果include因为某些原因只包含页面目录服务端类型自然同步不过去。4.2 Prisma 迁移时字段不匹配的坑Prisma 本地开发默认用 SQLite 很轻量但换到线上要用 PostgreSQL。两个库的差异最容易翻车的点就是 SQLite 不支持 Array 类型和某些原生函数。比如我想给OrgMember添加一个权限列表直觉上做成String[]这在 PostgreSQL 里是原生支持的但 SQLite 下 Prisma 会直接报错。解决方案有两种如果只是开发环境用 SQLite那String[]可以保留真要换库时迁移到 PostgreSQL 前先做数据格式化另一种更稳妥的做法是用 JSON 字段存数组靠应用层解析。另一个坑更隐蔽如果在schema.prisma里给已存在的表加了必填字段却没有默认值prisma migrate dev会提示你迁移存在风险因为数据库里已有的老数据会缺少这个字段。我建议首次开发阶段能不上数据的风险就不上数据直接用npx prisma migrate reset --force这个命令会把数据库重置为初始状态并应用所有迁移。但千万记住这是一个破坏性操作只适合开发环境线上环境绝对不要执行。4.3 部署时环境变量缺失导致构建失败t3code 整个项目依赖的环境变量不少DATABASE_URL、NEXTAUTH_SECRET生产环境必须显式配置、NEXTAUTH_URL。很多人本地跑得好好的一部署到 Vercel 或自己的服务器上就立刻启动失败原因基本都出在NEXTAUTH_SECRET漏配。NEXTAUTH_SECRET 的生成方式非常简单openssl rand -base64 32在本地开发如果没配这个值NextAuth 通常会自动生成一个临时的但线上环境它必须稳定持久否则每次重启后人人都得重新登录。t3code 里我把环境变量的读取统一放在/src/env.mjs用 Zod 做运行时校验启动时环境变量不满足要求就直接报错退出避免带着缺变量的配置跑进半可用的状态。4.4 数据获取时的 N1 查询问题用 Prisma 时最常见的性能问题就是 N1 查询。场景前端展示组织下的成员列表你还想看每个成员的邮箱。直觉写法是await db.orgMember.findMany({ where: { orgId } });拿到所有成员后再循环查每个用户的邮箱。一个组织如果有 20 个成员就是 1 20 次数据库查询。在 t3code 里我尽量预先用include一次性把关联数据查出来await db.orgMember.findMany({ where: { orgId }, include: { user: { select: { email: true, name: true, image: true } } }, });这样数据库只需要一条查询就能把所有关联数据带回来。Prisma 的另一个坑是千万不要在小结果集上做乐观猜想的取全量逻辑。以后数据量上去后orderBy、take、skip这些分页属性最好从设计模型时就想清楚。4.5 开发期常见问题速查表现象原因解决步骤前端看不到新增 tRPC 路由类型未刷新重跑tsc --noEmit或重建设置prisma migrate 报字段冲突schema 已变更但存在旧数据开发环境执行migrate reset登录后跳转回登录页NEXTAUTH_SECRET 缺失用 openssl 生成并写入 env页面样式偶尔闪现未样式化内容Tailwind 类名未编译完整确认content包含所有组件路径API 调用返回 500 但本地正常线上数据库结构不一致对比线上与本地 migration 状态NextAuth 报database sessionsession 策略与模型不匹配统一使用 JWT 策略5. 从 t3code 延展业务模块再往下怎么长5.1 从脚手架到业务功能的扩展思路t3code 提供了用户、组织、角色这些基础结构但它不是终点。只要掌握了数据模型 tRPC 路由 服务端裁剪 UI 组合这个循环业务模块就能源源不断长出来。我通常推荐的扩展顺序是先加权限点比如把orgMember.role扩展成角色转权限点数组每次路由授权时判断当前角色是否包含目标权限再加订阅与计费把 Stripe 的customerId直接挂在Org模型上通过 webhook 更新订阅状态然后做审计日志在所有写操作里追加一条操作记录这能省掉事后排查线上事故的大量痛苦。这里有一个核心心法为每个新模块只创建一条链路而不是为每种业务场景单独建一条链路。也就是说新增一个清单列表功能不应该再复制粘贴一套 tRPC 配置而是在router目录下新增一个文件用t.router组合进去再在页面中创建一个api和components目录沿同一套目录与命名规范走。5.2 从零搭建到稳定运维的几条个人心得t3code 走到现在我最大的感受是真正重要的不是框架本身而是你能不能把技术方案变成一条可持续演进的路。每一次新增业务前先花十分钟把 schema 改好、把校验写好、把路由封装好后面写页面的时间就会节省一大块。我个人还有一些坚持的小习惯第一所有 tRPC 路由里的输入校验一律使用 Zod哪怕只有一个字段也不能偷懒。前端可以省后端不能省这是底线。第二所有跨表查询的写操作尽量放在事务里Prisma 的$transaction很好用不要让业务数据陷入中间状态。第三尽量在本地把数据模型和权限规则设计完整了再上测试环境否则每次环境迁移都要处理数据兼容问题这种成本会随着团队扩大直线上升。如果你打算拿 t3code 作为自己项目的起点我强烈建议你先把schema.prisma完整读一遍再跑通一个完整的创建组织 → 邀请成员 → 成员认领账号流程。这个流程每一步都会涉及数据模型、鉴权逻辑、路由封装和前端调用跑通一次之后后续所有业务开发都会顺畅很多。