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);
/* ... */
}关键陷阱有两条:
@theme inline的inline关键字是必须的,它告诉 v4"不要把这些值固化,运行时仍读 CSS variable"。这样亮/暗主题切换才不用重 build--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:
| 主题 | 值 |
|---|---|
| Light | oklch(0.66 0.16 196) |
| Dark | oklch(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.tsx 的 generateStaticParams 没排除 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.tsx 的 generateStaticParams 调用 getMdxProjectSlugs(),自动排除已有具体路由的项目。集合放在 mocks 文件里,因为它本质上是项目元数据的一部分。
装好之后还剩什么
到 Sprint 1 收尾,这套骨架跑得稳了:typecheck 干净、build 17 个静态页全 PASS、Turbopack 编译 5 秒上下。后续 Sprint 2/3 的工作就是在这个稳定底座上加内容(博客 / 作品集 / OG / SEO),不再频繁与"新版本撞墙"周旋。
写给后来的自己:如果再开一个类似的项目,把这篇文章的"4 坑 + Monorepo 原则 + 路由优先级"先写到 AGENTS.md,大概能省一晚上的时间。