资讯动态

全栈项目开发模板设计:基于T3 Stack的工程实践与踩坑记录

发布时间:2026/10/28 7:13:16 来源:尧图企业网站定制
每次要新开一个全栈项目我都有个很不好的习惯先翻以前项目的 Git 仓库把“大概能用”的代码复制一遍再花一下午删掉那些跟当前需求无关的模块。删到最后发现真正属于自己的积累少得可怜——认证要重新接数据库要重新配API 层又因为翻了版本踩一堆坑。后来我花了两周时间把手头几个项目的公共部分收拢到一个独立代码仓库里给它起了个名字叫t3code核心就是 T3 StackNext.js TypeScript Tailwind CSS tRPC的组合把认证、数据库、API、前端基础组件、部署脚本全部沉淀成一套可以直接抄作业的全栈模板。这篇就把我的设计思路、踩坑记录和实际落地过程写出来给想开新项目又不想从零搭脚手架的朋友当个参考。1. 从零攒一个“t3code”这个项目想解决什么问题1.1 为什么偏偏选 T3 Stack先说清楚t3code 不是一个官方项目也不是什么框架它是我自己的代码仓库名。选 T3 Stack 这条技术路线原因很朴素我只想用一个 TypeScript 技术栈搞定全链路不想在 React 页面、Node 接口、数据库访问层之间来回切换语言和心智模型。T3 Stack 通常指 Next.js 做应用框架、TypeScript 做类型系统、Tailwind CSS 写着色和排版、tRPC 做前后端接口通信。这套东西相比传统的“前端 React 后端 Express RESTful API”写法最大的区别在于类型是端到端共享的。我写一个 tRPC 查询函数前端调用它的时候TypeScript 能直接推断出入参和返回类型不需要手写接口文档也不需要在前端复制粘贴一套 interface。我见过很多团队把前后端拆成两个仓库API 用 OpenAPI 生成类型但一旦接口改动前端还是要跑一遍代码生成器偶尔还会出现类型对不上号的情况。tRPC 的思路是后端定义的过程procedure本身就是前端可以调用的函数引用。类型不一致的问题从机制上就消失了。1.2 复制粘贴项目的代价比我想象中大在这套模板成型之前我开过好几个内容类项目每开一个新坑重复劳动大概有这些重新搭建 Next.js 项目装 Tailwind配字体和基础布局重新接认证包括邮箱登录、Session 管理、登录后跳转逻辑重新建数据库表至少要有用户表、内容表、标签表重新写 CRUD API包括列表分页、详情、创建、更新、删除重新封装前端请求逻辑、提示组件、表单组件这还只是基础。真正烦人的是那些“当时没觉得重要、后期天天用到”的东西比如统一的错误处理、统一的日志格式、环境变量管理、数据库迁移流程。复制粘贴的时候这些东西七零八落散落在各个项目里找起来全靠记忆。t3code 的出发点就是把这类公共能力固定下来。它的定位不是“开箱即用的低代码平台”而是一副骨架——我把自己习惯的目录结构、模块划分、命名规范全部固化在这个仓库里新项目直接 fork 或者复制目录替换业务表就能开工。1.3 这个模板适合谁如果你符合下面任意一条可以参考 t3code 的思路经常做中小型全栈应用App 体量在几个页面到几十个页面之间受够了前后端类型不一致每次改接口都要去前端同步类型不想一开始就上 monorepo 微服务那样重的架构需要一个能快速演示、快速部署给客户或朋友看的原型反过来说如果你要做的是一个大型 B 端系统或者需要复杂权限矩阵的后台t3code 就未必够用。tRPC 在处理跨服务调用、消息队列、多端接入时并不比传统 REST 更有优势那种场景可能需要更明确的 API 层边界。2. 项目骨架怎么设计目录结构、数据模型与模块边界2.1 目录结构先让代码找得到再谈效率t3code 的目录设计没有追新而是遵循一个原则每个新加入项目的人包括三个月后的我自己能凭直觉找到对应代码。最终我定下的主干结构是这样t3code/ ├── prisma/ │ ├── schema.prisma │ └── migrations/ ├── src/ │ ├── app/ │ │ ├── (auth)/ │ │ │ ├── login/ │ │ │ └── register/ │ │ ├── (main)/ │ │ │ ├── dashboard/ │ │ │ └── posts/ │ │ ├── api/ │ │ │ └── trpc/ │ │ ├── layout.tsx │ │ └── page.tsx │ ├── components/ │ │ ├── ui/ │ │ └── shared/ │ ├── server/ │ │ ├── api/ │ │ │ ├── routers/ │ │ │ └── root.ts │ │ ├── auth/ │ │ └── db.ts │ ├── styles/ │ └── lib/ │ ├── utils.ts │ └── validations/ ├── env.ts ├── docker-compose.yml └── package.json这里面有几个我特意坚持的细节。src/server/api/routers是 tRPC 路由的存放位置每个业务模块一个文件比如postRouter.ts、userRouter.ts。src/lib/validations用来放 zod 校验 schema这些 schema 可以在前端表单和后端接口之间复用避免两头写校验规则。src/app/(auth)和src/app/(main)是 Next.js 的 Route Group 写法括号不会出现在 URL 路径里。把登录注册页面放到(auth)组里把主要业务页面放到(main)组里可以针对每组分别定义布局文件比如(main)/layout.tsx里放导航栏(auth)/layout.tsx放居中的卡片样式。2.2 数据模型我用一个“内容发布”场景来做示范为了不让你看着一堆抽象概念犯困我拿一个典型的内容发布场景说明数据模型怎么设计。t3code 内置的示例模块包含用户、文章、标签这三张表一个用户有多篇文章一篇文章有多个标签标签和文章是多对多关系。Prisma schema 的核心部分长这样model User { id String id default(cuid()) email String unique name String? passwordHash String? image String? posts Post[] createdAt DateTime default(now()) updatedAt DateTime updatedAt } model Post { id String id default(cuid()) title String slug String unique content String published Boolean default(false) author User relation(fields: [authorId], references: [id]) authorId String tags PostTag[] createdAt DateTime default(now()) updatedAt DateTime updatedAt } model Tag { id String id default(cuid()) name String unique posts PostTag[] } model PostTag { post Post relation(fields: [postId], references: [id]) postId String tag Tag relation(fields: [tagId], references: [id]) tagId String id([postId, tagId]) }这里有个值得说的点用户表里的passwordHash我设计成String?也就是可空。原因是为了兼容两种登录场景——邮箱密码登录时这个字段有值使用第三方账号登录比如 GitHub OAuth时走的是授权服务本地不存密码。这样同一个 User 模型可以同时服务于多种认证来源不用为每种登录方式单独建表。2.3 模块边界说什么也不把业务写成一个大文件t3code 里有一个不成文的规定一个 router 文件最多对应一个业务域超过 100 行的 router 必须拆分。这不是代码洁癖而是我在实际项目中吃过亏。之前在另一个项目里我写过一份 400 多行的blogRouter.ts里面既有文章 CRUD又有评论、点赞、置顶、统计。结果某个页面需要按阅读量排序我去改查询摸半天才找到对应函数还差点把点赞逻辑改坏。t3code 从一开始就按用户、文章、标签、文件上传这样的边界拆 router列表和详情这种基础操作放在同一个模块里但业务规则各自独立。模块边界的另一个体现是“服务内外分离”。tRPC router 直接接收来自前端的调用但不允许它直接操作数据库。我习惯在src/server/api/routers里调用src/server/下的服务函数服务函数负责处理业务逻辑和数据库交互router 只做参数校验、权限判断和返回格式化。比如创建文章的流程是router 收到 inputzod 校验 title、slug、content从 session 里取当前用户 ID调用createPostService写入数据库返回给前端这样将来如果要加 REST API 或者内部定时任务可以直接复用服务函数不用跟 tRPC 耦合在一起。3. 跑通核心链路tRPC API Prisma 认证这套组合拳3.1 为什么 tRPC 比手写 REST 接口顺手如果你已经习惯了写 REST API第一次接触 tRPC 会有种“这也能行”的错觉。简单讲tRPC 就是让客户端直接调用服务端函数而网络请求的细节URL、序列化、状态码被封装掉了。REST 里一次查询要经历设计 URL、定 HTTP method、写 controller、写 service、写参数校验、前端再写一遍请求函数和类型。而在 tRPC 里后端定义一个 queryexport const postRouter router({ list: protectedProcedure .input( z.object({ page: z.number().min(1).default(1), pageSize: z.number().min(1).max(50).default(10), tag: z.string().optional(), }) ) .query(async ({ ctx, input }) { const where { published: true, ...(input.tag ? { tags: { some: { tag: { name: input.tag } } } } : {}), }; const [items, total] await Promise.all([ ctx.db.post.findMany({ where, skip: (input.page - 1) * input.pageSize, take: input.pageSize, include: { tags: { include: { tag: true } } }, }), ctx.db.post.count({ where }), ]); return { items, total, page: input.page, pageSize: input.pageSize, }; }), });前端调用时只需要const { data } api.post.list.useQuery({ page: 1, pageSize: 10 });data的类型自动就是后端返回的{ items: Post[]; total: number }。不用查文档不用手写类型改返回值前端立刻报错。这就是 t3code 选择 tRPC 的核心理由。3.2 Prisma 与数据库迁移的日常流程数据库访问我统一用 Prisma。选择它的原因主要是 schema 文件可读性好、迁移流程成熟。迁移的日常流程我在 t3code 里固定为三件事改schema.prisma执行npx prisma migrate dev --name 描述把生成的 SQL 提交进仓库prisma migrate dev会对比上次迁移和当前 schema 的差异生成一次性迁移文件并自动应用到本地开发数据库。这里需要注意不要在多人协作时手动改数据库表结构必须通过迁移文件同步否则同事电脑上的库就跟你这边不一致了。开发环境我用 Docker 跑一个 PostgreSQL# docker-compose.yml services: db: image: postgres:16-alpine ports: - 5432:5432 environment: POSTGRES_USER: t3code POSTGRES_PASSWORD: t3code_dev POSTGRES_DB: t3code_dev volumes: - pgdata:/var/lib/postgresql/data volumes: pgdata:不需要装本地数据库客户端Docker 起的实例足够日常开发用。上线前把连接串换成托管数据库的地址即可。3.3 认证接入从邮箱密码到第三方登录都预留口子t3code 的认证方案走在一条相对保守的路线上用 NextAuth现在叫 Auth.js托管 Session数据库里存 Session 记录服务端通过 tRPC context 拿到当前用户。关键实现分三步。第一步配置认证选项。以邮箱密码登录加 GitHub 登录为例// src/server/auth.ts import NextAuth from next-auth; import GitHub from next-auth/providers/github; import Credentials from next-auth/providers/credentials; import { PrismaAdapter } from auth/prisma-adapter; import { db } from /server/db; export const { handlers, auth, signIn, signOut } NextAuth({ adapter: PrismaAdapter(db), session: { strategy: database }, providers: [ GitHub, Credentials({ credentials: { email: { label: 邮箱, type: email }, password: { label: 密码, type: password }, }, authorize: async (credentials) { // 从数据库查询用户比对密码哈希 }, }), ], callbacks: { session({ session, user }) { // 把 userId 挂到 session 上 }, }, });第二步在 tRPC 的初始化文件里创建上下文把 session 注入到每个 procedure 里// src/server/api/trpc.ts import { initTRPC, TRPCError } from trpc/server; import { auth } from /server/auth; export const createTRPCContext async (opts: { headers: Headers }) { const session await auth(); return { ...opts, db, session, }; }; const t initTRPC.contexttypeof createTRPCContext().create(); 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 } }); });第三步业务 router 在需要登录的接口上用protectedProcedure替代publicProcedure。比如创建文章create: protectedProcedure .input(postCreateSchema) .mutation(async ({ ctx, input }) { const post await ctx.db.post.create({ data: { ...input, authorId: ctx.user.id, }, }); return post; }),这套链路跑通之后前端拿到返回的post类型是完整的作者 ID 不用前端传后端从 Session 里取伪造请求也拿不到别人的身份。4. 前端组件与样式Tailwind 配置的进阶玩法4.1 Tailwind 主题配置先统一再谈美观很多人 Tailwind 用走样是因为想到什么类名就写什么类名颜色五花八门。t3code 在 Tailwind 配置阶段就把设计变量固定下来。我的做法是扩展tailwind.config.ts定义语义化的颜色令牌而不是直接用blue-600、gray-100import type { Config } from tailwindcss; const config: Config { darkMode: class, theme: { extend: { colors: { primary: { DEFAULT: #2563eb, hover: #1d4ed8, light: #dbeafe, }, surface: { DEFAULT: #ffffff, muted: #f8fafc, }, text: { DEFAULT: #0f172a, muted: #64748b, }, }, borderRadius: { DEFAULT: 0.5rem, }, }, }, plugins: [], }; export default config;这样在组件里你看到的不再是bg-blue-600 hover:bg-blue-700而是bg-primary hover:bg-primary-hover。好处是品牌色调整时只改配置一处全局同步。我后来接一个客户项目对方要求按钮从蓝改成绿前后只花了五分钟。4.2 基础组件封装别没完没了造轮子但也别没有轮子t3code 里内置了几个高频基础组件包括按钮、输入框、标签、弹窗、Toast。这些组件的封装原则是视觉样式统一行为逻辑简单不全宇宙功能塞进去。以按钮为例组件要支持的变体标签包括primary、secondary、ghost、danger尺寸包括sm、md、lg。实现时用cvaclass-variance-authority来管理样式变体import { cva, type VariantProps } 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-offset-2 disabled:pointer-events-none disabled:opacity-50, { variants: { variant: { primary: bg-primary text-white hover:bg-primary-hover, secondary: bg-surface-muted text-text hover:bg-slate-200, ghost: hover:bg-surface-muted hover:text-text, danger: bg-red-600 text-white hover:bg-red-700, }, size: { sm: h-8 px-3 text-xs, md: h-10 px-4, lg: h-12 px-6 text-base, }, }, defaultVariants: { variant: primary, size: md, }, } );这里有个容易忽略的细节cva的返回值不只是静态函数还能做类型派生。按钮组件的 props 里直接用VariantPropstypeof buttonVariants把variant、size的类型带给用户编辑器里写错变体名会立刻标红。4.3 服务端组件和客户端组件的边界Next.js App Router 下组件默认是服务端组件只有标记use client的文件里才能用交互逻辑。t3code 里有几条不成文的约定凡是依赖 tRPC 查询 hooks 的组件必须是客户端组件凡是只做静态渲染、读取服务端数据的组件保持服务端组件为了不把整个页面拉下水业务组件会尽量拆成“服务端容器 客户端叶子”的写法比如文章列表页面外层是服务端组件负责排版内层单独一个PostListClient组件负责数据请求和交互import { api } from /trpc/react; export function PostListClient() { const { data, isLoading } api.post.list.useQuery({ page: 1, pageSize: 10 }); if (isLoading) return div加载中.../div; return ( ul {data?.items.map((post) ( li key{post.id}{post.title}/li ))} /ul ); }这种边界在项目早期很难体会到价值但当 SSR 页面出现“水合不匹配”或者“useEffect 服务端执行”的报错时你会庆幸当初没把交互组件全部写在页面里。5. 最容易翻车的几个地方懒加载、SSR 和 tRPC 的冷知识5.1 页面数据不更新的坑根子出在 Next.js 缓存上我在 t3code 开发期遇到最诡异的一个问题新增文章后列表页怎么刷新都不出现。第一反应是数据库写入失败查了日志没有报错再怀疑 tRPC 缓存清空也不行。最后发现是 Next.js App Router 的静态渲染缓存把页面请求缓存住了。现象是开发环境下页面正常部署到生产环境后列表不更新。排查链路大致是这样检查数据库数据确实写进去了打开网络面板发现/posts页面直接返回 200没有走 tRPC 请求页面在服务端被完整渲染了查看页面组件的动态 API 使用情况发现我没有在页面里调用任何动态函数Next.js 把它当作静态页面预渲染了解决方案是让页面强制走动态渲染。在页面文件顶部加一条export const dynamic force-dynamic;或者在数据读取上依赖cookies()、headers()这类动态函数Next.js 会自动把页面标记为动态。我更推荐后者因为它是“用行为告诉框架这个页面不可能是静态的”而不是用指令一刀切。你如果从纯客户端渲染的框架比如 Vite React切换过来肯定会在这个问题上多花一点时间。记住一个原则App Router 的默认倾向是“能静态就静态”当你发现页面数据不更新时优先检查缓存策略而不是业务代码。5.2 tRPC context 初始化和鉴权时机另一个容易踩的坑是 tRPC context 里拿不到 session。Auth.js 的auth()函数依赖请求上下文在createTRPCContext里调用本身没问题但在 App Router 下如果 context 是在请求之外被调用的比如被某个工具函数直接 importsession 会变成null所有保护接口都会神秘地返回未授权。我在 t3code 里把 context 创建函数定义成异步并且在每次 tRPC 请求时调用。这一点不要偷懒不要尝试把一个全局 session 缓存起来——用户登录状态是会变的缓存 session 会导致“用户注销了还能继续访问”的严重问题。5.3 Prisma 连接池在 Serverless 环境的坑如果你打算把 Next.js 部署到 VercelPrisma 连接池的配置需要特别注意。每个 Serverless 函数实例都可能独立打开数据库连接连接数很容易被打满。t3code 里用一个全局单例来避免重复初始化// src/server/db.ts import { PrismaClient } from prisma/client; const globalForPrisma globalThis as unknown as { prisma?: PrismaClient }; export const db globalForPrisma.prisma ?? new PrismaClient({ log: process.env.NODE_ENV development ? [query, error] : [error], }); if (process.env.NODE_ENV ! production) { globalForPrisma.prisma db; }同时在环境变量里设置连接池参数比如 PostgreSQL 连接串里的pool_timeout和connection_limitDATABASE_URLpostgresql://user:passhost:5432/db?schemapublicconnection_limit5pool_timeout10这会牺牲一部分并发上限但换来了 Serverless 环境下的稳定。我在本地联调时不会有感觉压力测试到 200 并发时连接池限制的价值就显现出来了。6. 部署与后续扩展从 Vercel 到自托管t3code 还能怎么用6.1 一键部署到 Vercel 的完整步骤t3code 默认推荐部署到 Vercel因为 Next.js 在那个环境里的适配成本最低。部署步骤我整理成了固定清单把代码推到 GitHub 仓库Vercel 里新建项目导入仓库环境变量填DATABASE_URL、AUTH_SECRET、GITHUB_ID、GITHUB_SECRET部署命令用默认的next buildAUTH_SECRET是必须的执行openssl rand -base64 32生成一个。数据库方面我建议直接用托管 PostgreSQL比如 Neon 或 Supabase它们提供按使用量计费的 Serverless 数据库对个人项目友好。连接串填进环境变量之后不需要额外配置。6.2 自托管部署时的进程管理方案有些项目客户要求部署到自己的服务器t3code 也有对应的方案。核心思路是构建产物跑在 Node 进程上用 PM2 做守护Nginx 反代。流程如下服务器装 Node.js 20、pnpm克隆代码执行pnpm install执行pnpm build用 PM2 启动pnpm start端口默认 3000Nginx 配置把 443 端口的请求转发到 localhost:3000PM2 配置参考// ecosystem.config.cjs module.exports { apps: [ { name: t3code, script: node_modules/next/dist/bin/next, args: start -p 3000, env: { NODE_ENV: production, }, instances: 1, autorestart: true, max_memory_restart: 500M, }, ], };这里的max_memory_restart: 500M不是随便写的Next.js 在某些服务器上跑久了会内存上涨设置一个重启阈值能兜底。如果应用跑在 2GB 内存的小机器上更保险的做法是限制到 400M 或 450M。6.3 t3code 还能往哪些方向扩展模板把基础链路打通之后按需扩展的路径就很清晰了。文件上传增加一个uploadRouter配合 S3/OSS 或本地磁盘存储生成预签名 URL 或直接上传实时通知用 Server-Sent Events 或 WebSocket 实现评论、点赞的实时推送tRPC 也支持 subscription多租户在 User 表上增加tenantId所有查询强制过滤租户维度相比从零搭建省一半工作量后台管理单独拉一个 admin 路由组复用现有 tRPC router只是多包一层管理员权限校验我个人更推荐按“先上线核心链路再补业务模块”的节奏来用这套模板。你不需要一次把文件上传、消息推送都做完t3code 的价值在于把那些每次都重复的底层工作收敛掉让你能把精力放在真正有差异的业务上。最后再分享一个小技巧如果你决定 fork 这套模板做自己的项目记得把prisma/migrations目录完整保留不要删掉重建。迁移历史是你的数据库演进日志以后要回滚或者从零恢复环境时它比任何文档都可靠。t3code 这两个字对别人来说可能只是个仓库名但对我来说它意味着每次新项目启动时不再从零开始。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑