用 Bun + Next.js 15 重写 vinceai.tech:一次现代全栈的实操记录
为什么选 Bun workspaces、为什么用 App Router 的 [locale] 动态段做 i18n、Vercel 部署 monorepo 的几个坑,以及把整个站点 Lighthouse 拉到 100 分的一些细节。
vinceai.tech 是工作室自己的官网。这是一个我们「客户都是自己」的项目,所以可以放开手做工程实验。这篇笔记把整个站点重写过程中真实碰到的几个决策和坑写出来,给同样要做现代全栈交付的同行参考。
技术栈选型
整体放在一个 Bun workspaces monorepo 里:
- packages/web — 站点本体,Next.js 15 App Router + Tailwind v4
- packages/server — 联系表单收件接口,Elysia + Drizzle
- packages/shared — 品牌常量、邮件模板、跨端共享类型
选 Bun 的核心理由不是「快」,而是 install / run / test / build 一套工具链都不出 Bun,少了一层 npm/yarn/pnpm 与 ts-node/tsx 的组合复杂度。代价是 Vercel 部署需要一点配置(下文会讲)。
i18n:用 [locale] 动态段而不是中间件改写
Next.js 官方 i18n 文档现在主要推荐 App Router 下做路由分段。我们最终选了 [locale] 动态段方案:
app/
├── layout.tsx // 仅 <html><body> 壳
├── [locale]/
│ ├── layout.tsx // 真正的页面布局 + Nav / Footer
│ ├── page.tsx // /zh, /en
│ ├── about/page.tsx
│ └── ...
└── middleware.ts // 仅做重定向 + cookie 写入好处:每一页都是 SSG,dictionary 在 server component 里同步取,不需要任何 client provider;Vercel 上每个 locale 有自己的静态产物。
坑点:dictionary 的 TypeScript 类型必须从 zh.ts 推导(typeof zh),en.ts 用 Dictionary 类型来强制保证 key 同步。但如果 zh.ts 加了 as const,类型会被字面量化,en.ts 里所有字符串都会变成 TS2322 类型错误 — 务必去掉 as const。
Vercel 部署 Bun monorepo 的两个坑
1) 不要在 packages/web 子目录下单独绑 Vercel 项目。Vercel 默认用 npm install,遇到 workspace:* 协议会直接失败:
npm error Unsupported URL Type "workspace:": workspace:*正确做法是从 monorepo 根目录绑项目,让 Vercel 识别 bun.lockb 并自动用 bun install。
2) Next.js cache 问题。把 app/ 下的页面挪到 app/[locale]/ 之后,.next/types/ 下还残留旧路径的引用,导致 build 失败。直接 rm -rf .next 重新 build 就好。
动效:先做 hover,再补 idle
我们一开始照 uiverse 风格给 CTA 做了一组 :hover 动效(背景流光、shine 扫过、halo 浮现)。在桌面端看着很顺,但移动端没有 hover —— 用户看到的就是一个静态胶囊。
解决方式是把所有动效拆成两层:idle 状态用 CSS animation 持续运行,hover 状态用 transition 强化。所有动画都尊重 prefers-reduced-motion,并且只用 transform / opacity / background-position / box-shadow / filter,全 compositor-only,不会影响主线程。
Lighthouse 100 的几个小细节
- 字体不用 Google Fonts,直接 next/font/local 自托管 — 减少一次 DNS
- icon / OG image 用 Next.js metadata route(app/icon.tsx、app/opengraph-image.tsx),avoid CLS
- 所有 useEffect 里的 querySelector 都要先 null check,否则 SSR 阶段 React 19 会丢警告
- Tailwind v4 的 @utility 比 @layer components 更轻,class 复用就用 @utility 包
下一步
下一篇会写联系表单那条链路 —— Cloudflare Turnstile 验证 + Elysia 路由 + 飞书 Webhook 通知,怎么在不引入数据库的前提下做到「邮件不丢」。