vibecoding.site
← back to blog
约 11 分钟3 标签

Next.js 16 + Tailwind v4 + shadcn/ui:把骨架塞进 monorepo

App Router 的 PageProps 新写法,Tailwind v4 的 @theme inline,以及 pnpm workspace 的依赖共享。


title: Next.js 16 + Tailwind v4 + shadcn/ui:把骨架塞进 monorepo slug: nextjs-16-tailwind-v4-monorepo publishedAt: 2026-05-06 excerpt: App Router 的 PageProps 新写法,Tailwind v4 的 @theme inline,以及 pnpm workspace 的依赖共享。 tags: [nextjs, tailwind, monorepo] readingMin: 11 wordCount: 1580 draft: false

选型不是难点,装起来才是

技术栈写出来很直白:

  • 前端:Next.js 16.2.6 + React 19 + TypeScript strict + Tailwind v4 + shadcn/ui + framer-motion + next-themes
  • 后端:Fastify 5 + Prisma 6 + Zod + SQLite
  • Monorepo:pnpm workspace

但每个组件都是它自己的"新版本撞墙现场":Next.js 16 的 App Router 有几条 breaking,Tailwind v4 完全换了 CSS 引擎,shadcn/ui 也跟着升级了组件 API。真正的工作量不是写代码,是让这一堆新版本和平共处

这篇把第一周踩到的每个坑挨个写一遍,顺便讲一下我们最终的目录结构是怎么稳下来的。

第一坑:searchParams 是 Promise

Next.js 15 起,App Router 的页面 props 全面变成 Promise。/blog 列表页第一稿写成这样:

export default function BlogList({ searchParams }: { searchParams: { tag?: string } }) {
  const tag = searchParams.tag;
  // ...
}

TypeScript 直接红线。新的 PageProps 形如:

export default async function BlogList({
  searchParams,
}: {
  searchParams: Promise<{ tag?: string }>;
}) {
  const { tag } = await searchParams;
  // ...
}

params 一样的写法。这条必须记在 apps/web/AGENTS.md 里,否则下一轮 invocation 的 Claude 会重复踩。

第二坑:Tailwind v4 的 @theme inline

Tailwind v4 把"主题变量"机制完全换了。v3 是 tailwind.config.ts 的 JS 对象,v4 是 CSS-first 的 @theme directive,直接写在 CSS 里

我们的 apps/web/app/globals.css 顶部长这样:

@import "tailwindcss";
 
@theme inline {
  --color-brand: var(--brand);
  --color-brand-foreground: var(--brand-foreground);
  --color-brand-muted: var(--brand-muted);
  --font-sans: var(--font-inter);
  --font-mono: var(--font-jetbrains-mono);
  /* ... */
}
 
:root {
  --brand: oklch(0.66 0.16 196);
  --brand-foreground: oklch(0.99 0 0);
  /* ... */
}
 
.dark {
  --brand: oklch(0.78 0.14 196);
  /* ... */
}

关键陷阱有两条:

  1. @theme inlineinline 关键字是必须的,它告诉 v4"不要把这些值固化,运行时仍读 CSS variable"。这样亮/暗主题切换才不用重 build
  2. --color-* 这种前缀对应 utility class 生成。如果只写 --brand 而不在 @theme 里映射到 --color-brand,bg-brand 这个 utility 不存在

我们因为漏写第二条踩了一次,Claude1 写 <Button className="bg-brand"> 编译过但运行时无样式,debug 了 20 分钟。

oklch 而非 hex

shadcn/ui 在 2024 年底全面迁移到 oklch 色彩空间。优点是色相、亮度、饱和度独立可调,亮/暗主题用同色相、只变亮度,过渡天然平滑。代价是模型对 oklch 不熟,经常生成"看起来奇怪"的颜色。

我们的解决办法是固定 brand 色相 196(cyan/teal),只让模型调亮度和 chroma:

主题
Lightoklch(0.66 0.16 196)
Darkoklch(0.78 0.14 196)

亮度从 0.66 → 0.78 是为了在暗背景上拉够对比度;chroma 从 0.16 → 0.14 让暗色不会过曝。

第三坑:@base-ui/react/button 没有 asChild

shadcn 升级后内部组件用了 @base-ui/react(原 Radix UI 的下一代)。Base UI 的设计哲学是 render prop 取代 asChild

旧 Radix 写法:

<Button asChild>
  <Link href="/blog">Go</Link>
</Button>

Base UI 写法是 render prop,但 shadcn 还没把这个用法封装到 <Button> 里。最干净的兜底是给 Link 套 buttonVariants:

import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
 
<Link href="/blog" className={cn(buttonVariants({ variant: "outline" }))}>
  Go to Blog
</Link>

如果在多处用,可以封一个 <ButtonLink> 组件,但项目早期不必要。

第四坑:Turbopack 的 MDX 插件约束

Next.js 16 默认 Turbopack。MDX 插件的传参方式变了:只能传字符串名 + 可 JSON 序列化的 options,因为 Rust runtime 不能跨边界拿到 JS 函数引用。

错的写法:

import remarkGfm from "remark-gfm";
import rehypeSlug from "rehype-slug";
 
const withMDX = createMDX({
  options: {
    remarkPlugins: [remarkGfm],  // ❌ Turbopack 报错
    rehypePlugins: [rehypeSlug],
  },
});

对的写法:

const withMDX = createMDX({
  extension: /\.(md|mdx)$/,
  options: {
    remarkPlugins: ["remark-gfm"],
    rehypePlugins: [
      "rehype-slug",
      [
        "rehype-autolink-headings",
        { behavior: "wrap", properties: { className: ["heading-anchor"] } },
      ],
      [
        "rehype-pretty-code",
        { theme: { light: "github-light", dark: "github-dark" }, keepBackground: false },
      ],
    ],
  },
});

这条必须写到 next.config.ts 的注释里。我们第一次踩这个坑是 T103 写 MDX 详情页时,Claude1 凭印象写了函数引用,build 直接死在 plugin loader 阶段。

Monorepo 的目录:为什么是这个结构

E:/web项目/
├── apps/
│   ├── web/          # Next.js 前端
│   └── api/          # Fastify 后端
├── package.json      # workspace root,只放 lint / typecheck 命令
├── pnpm-workspace.yaml
└── .npmrc

没有 packages/ 目录,因为现在还没有真正可以共享的代码。早期为了"显得专业"就拆 packages/ui / packages/types,反而要花精力维护包间依赖。我们的原则是:

当某段代码已经被两个 app 同时 import 了,再抽到 packages/。在那之前,复制粘贴是更便宜的工程选择

pnpm-workspace.yaml 极简:

packages:
  - "apps/*"

.npmrc 一行 node-linker=hoisted,让 Next.js / shadcn CLI 这类对 node_modules 结构敏感的工具不会因为 pnpm 默认的 isolated 安装跑炸。

共享依赖的小技巧

workspace 范围内,zod / typescript 这种两个 app 都要用的依赖,可以提到根 package.json,但前提是版本能锁死一致。我们当前阶段是 每个 app 自己 declare —— 装两份对硬盘没什么压力,好处是 app 可以独立升级

哪天前后端要共享 zod schema(目前还没到这一步),才会拆出 packages/schemas 并把 zod 提到 root。

路由的优先级:Next.js 静态 > [slug]

/works 同时存在三种路由:

  • /works(列表)
  • /works/markdown-poster(具体 demo 页)
  • /works/csv-charts(具体 demo 页)
  • /works/[slug](其它项目的详情)

Next.js 的路由解析总是静态优先:/works/markdown-poster 会先尝试匹配同名文件夹的 page.tsx,匹配失败才落到 [slug]。这点对开发者直觉友好,但 SSG 会把所有 slug 一起 build,如果 [slug]/page.tsxgenerateStaticParams 没排除 markdown-poster,就会产出两条同 URL 的 prerender,带来 ambiguous route 风险。

我们在 _mocks/projects.ts 里维护了一个 REAL_ROUTE_SLUGS 集合作 single source of truth:

export const REAL_ROUTE_SLUGS = new Set(["markdown-poster", "csv-charts"]);
 
export function getMdxProjectSlugs(): string[] {
  return MOCK_PROJECTS
    .filter((p) => !REAL_ROUTE_SLUGS.has(p.slug))
    .map((p) => p.slug);
}

[slug]/page.tsxgenerateStaticParams 调用 getMdxProjectSlugs(),自动排除已有具体路由的项目。集合放在 mocks 文件里,因为它本质上是项目元数据的一部分。

装好之后还剩什么

到 Sprint 1 收尾,这套骨架跑得稳了:typecheck 干净、build 17 个静态页全 PASS、Turbopack 编译 5 秒上下。后续 Sprint 2/3 的工作就是在这个稳定底座上加内容(博客 / 作品集 / OG / SEO),不再频繁与"新版本撞墙"周旋。

写给后来的自己:如果再开一个类似的项目,把这篇文章的"4 坑 + Monorepo 原则 + 路由优先级"先写到 AGENTS.md,大概能省一晚上的时间。

评论

加载评论中…

留下评论 · 提交后由 PM 审核显示

评论无需注册,审核通过后可见。