← 返回博客
Engineering·6 分钟

用 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 通知,怎么在不引入数据库的前提下做到「邮件不丢」。

用 Bun + Next.js 15 重写 vinceai.tech:一次现代全栈的实操记录 · Vince 文启辰