Storybook for Next.js 开启 App Router 支持:`nextjs.appDirectory` 参数深度解析与实战配置
本文聚焦 Storybook 官方 Next.js 框架(@storybook/nextjs / @storybook/nextjs-vite)中用于启用 App Router 能力的核心参数 nextjs.appDirectory。当你的 Story 导入的组件内部调用了 next/navigation 的 hook(如 useRouter、usePathname、useSelectedLayoutSegment、useParams)时,必须把该参数设为 true,框架才会用 App Router 上下文包裹并模拟 next/navigation,否则这些依赖在 Story 渲染时会直接报错。读完本文,你将掌握该参数在单条 Story、组件级 Meta、全局 Preview 三个作用域的配置方法,理解其底层 Router/导航 Mock 的实现机制,并能配合 nextjs.navigation 精确模拟路由段与动态参数。
为什么需要 nextjs.appDirectory: true
Next.js 应用存在两种路由模型,二者对应的 Hooks 与 Context 完全不同,且互不通用:
- Pages Router:使用
next/router与useRouter(),对应pages/目录; - App Router:使用
next/navigation(useRouter、usePathname、useParams、useSelectedLayoutSegment(s)等),只能在app/目录下使用。
Storybook 的 Next.js 框架默认会对 next/router 做桩(stub)处理,自动注入一套模拟的 Page Router 上下文,因此 Pages Router 下的组件可以「零配置」直接渲染。但 next/navigation 不在默认模拟范围内——它依赖 App Router 特有的多层 React Context(AppRouterContext、PathnameContext、SearchParamsContext、PathParamsContext 等)。
从源码类型定义即可看到该参数的定位(code/frameworks/nextjs/src/types.ts):
export interface NextJsParameters {
nextjs?: {
/**
* 如果你的 Story 导入了使用 next/navigation 的组件,需要将该参数设为 true
*/
appDirectory?: boolean;
/** 使用 next/navigation 时的导航配置(仅适用于 app 目录下的组件/页面) */
navigation?: Partial<NextRouter>;
/** next/router 的路由器配置 */
router?: Partial<NextRouter>;
/** 传递给每个 next/image 实例的 props */
image?: Partial<NextImage.ImageProps>;
};
}
因此判断是否需要它的标准非常简单:只要 Story 渲染链路中的某个组件 import 并调用了 next/navigation,就必须开启 nextjs.appDirectory。官方推荐先看「框架是否直接支持」——@storybook/nextjs-vite 因构建更快、测试特性支持更好,被官方文档建议作为大多数 Next.js 项目的首选框架(docs/get-started/frameworks/nextjs.mdx);而 Webpack 版 @storybook/nextjs 仅在存在与 Vite 不兼容的自定义 Webpack/Babel 配置时才需选用。无论选择哪一个,本文讨论的 appDirectory 参数在两个框架中行为一致(@storybook/nextjs-vite 的 routing/decorator.tsx、preview.tsx 与 types.ts 中均有同名实现)。
配置方式一:在组件 Meta 中开启(本文核心)
最精确的做法是只对「包含导航依赖组件」的 Stories 文件开启,避免影响其他使用 Page Router 的 Stories。原文档示例在 docs/_snippets/nextjs-app-directory-in-meta.md 中提供了 CSF 3 与 CSF Next 两种书写范式。
CSF 3(TypeScript,推荐类型安全的写法):
// Replace your-framework with nextjs or nextjs-vite
import type { Meta, StoryObj } from '@storybook/your-framework';
import NavigationBasedComponent from './NavigationBasedComponent';
const meta = {
component: NavigationBasedComponent,
parameters: {
nextjs: {
appDirectory: true, // 👈 Set this
},
},
} satisfies Meta<typeof NavigationBasedComponent>;
export default meta;
CSF 3(JavaScript 等价写法):
import NavigationBasedComponent from './NavigationBasedComponent';
export default {
component: NavigationBasedComponent,
parameters: {
nextjs: {
appDirectory: true, // 👈 Set this
},
},
};
CSF Next(实验性新语法):如果你在 .storybook/preview 中通过 preview.meta(...) 声明 Meta,则可写为(TypeScript 与 JavaScript 两种形态结构一致):
import preview from '../.storybook/preview';
import NavigationBasedComponent from './NavigationBasedComponent';
const meta = preview.meta({
component: NavigationBasedComponent,
parameters: {
nextjs: {
appDirectory: true, // 👈 Set this
},
},
});
说明:
nextjs是标准的 Storybook Parameters 命名空间,遵守参数继承规则。CSF 3 与 CSF Next 两种范式只是在 Meta 声明方式上不同,parameters.nextjs.appDirectory的取值与生效语义完全一致。
配置方式二:在全局 Preview 中开启
若整个 Next.js 项目所有页面都位于 app/ 目录(即不存在 pages/ 目录),则无需在每个 Stories 文件里重复声明,可在 .storybook/preview 中一次性对全部 Story 生效,详见官方导航章节(docs/get-started/frameworks/nextjs.mdx)。
对应片段 docs/_snippets/nextjs-app-directory-in-preview.md 提供以下写法(TS / JS / CSF Next 的 definePreview 变体在此省略 JS 与重复形式):
// Replace your-framework with nextjs or nextjs-vite
import type { Preview } from '@storybook/your-framework';
const preview: Preview = {
// ...
parameters: {
// ...
nextjs: {
appDirectory: true,
},
},
};
export default preview;
CSF Next 范式下使用 definePreview:
// Replace your-framework with nextjs or nextjs-vite
import { definePreview } from '@storybook/your-framework';
export default definePreview({
// ...
parameters: {
// ...
nextjs: {
appDirectory: true,
},
},
});
同理,由于 Parameters 支持三层继承(全局 Project Parameters → 组件级 Meta Parameters → 单条 Story Parameters),你还可以只在某一个组件(如 meta.parameters)或某一条 Story 上开启——覆盖面越小,对使用 Pages Router 的其他 Stories 影响越小。类型层面,上述 NextJsParameters 中的 appDirectory?: boolean 默认值为 false(见 docs/get-started/frameworks/nextjs.mdx)。
源码视角:appDirectory 究竟改变了什么
开启该参数后,框架的装饰器与加载器会做两件事:换掉路由 Provider、换掉被 Mock 的导航模块来源。
1. 装饰器选择 App Router 还是 Page Router 上下文
在 code/frameworks/nextjs/src/routing/decorator.tsx 中,RouterDecorator 依据该参数进行分支:
export const RouterDecorator = (Story, { parameters }) => {
const nextAppDirectory =
(parameters.nextjs?.appDirectory as NextAppDirectory | undefined) ?? false;
if (nextAppDirectory) {
if (!AppRouterProvider) return null;
return (
<AppRouterProvider
routeParams={{ ...defaultRouterParams, ...parameters.nextjs?.navigation }}
>
<RedirectBoundary>
<Story />
</RedirectBoundary>
</AppRouterProvider>
);
}
return (
<PageRouterProvider>
<Story />
</PageRouterProvider>
);
};
也就是说:为 true 时,Story 被 AppRouterProvider 包裹(并额外包上 Next.js 的 RedirectBoundary,用于兼容 App Router 场景下的 redirect() 逻辑);为默认的 false 时,则退回到 PageRouterProvider(面向 next/router)。这正是模拟的 Context 来源不同的根因。
2. Loader 决定 Mock 哪个路由模块
框架的 Loader 同样以该参数为开关(code/frameworks/nextjs/src/preview.tsx):
export const loaders: Addon_LoaderFunction = async ({ globals, parameters }) => {
const { router, appDirectory } = parameters.nextjs ?? {};
if (appDirectory) {
createNavigation(router); // Mock next/navigation(App Router)
} else {
createRouter({ locale: globals.locale, ...router }); // Mock next/router(Pages Router)
}
};
可以看到:开启 appDirectory 后调用的是 createNavigation(...),把 next/navigation 的导出替换为可从测试/play function 中断言的 Mock;关闭时则用 createRouter(...) 模拟 next/router,并把全局的 locale(来自 Globals,常配合 Toolbar 使用)合并进 router 对象。
3. App Router Provider 实际注入了哪些 Context
进一步看 code/frameworks/nextjs/src/routing/app-router-provider.tsx,AppRouterProvider 会根据 routeParams 提供一套与 Next.js 官方对齐的 Provider 栈:
PathParamsContext.Provider:把 segments 中形如["slug", "hello"]的键值对解析为动态路由参数对象;PathnameContext.Provider:提供当前pathname(默认/);SearchParamsContext.Provider:用new URLSearchParams(query)提供searchParams;GlobalLayoutRouterContext.Provider与LayoutRouterContext.Provider:构造一份 Flight Router Statetree(来自pathname+segments),支撑 Layout 相关上下文;AppRouterContext.Provider:value 来自getRouter(),即@storybook/nextjs/navigation.mock暴露的 Mockrouter对象。
理解这层实现,就能解释为什么仅仅「开着参数」还不够——usePathname() 读到的是 Provider 里写入的默认 pathname: '/',useParams() 读到的是空对象。若组件依赖真实路由值,还要配合下一节的 navigation 参数。
配套导航模拟:nextjs.navigation 与默认值
开启 appDirectory 后,你通常还需要控制路由值。框架支持在每个层级通过 nextjs.navigation 参数做浅合并覆盖(docs/get-started/frameworks/nextjs.mdx)。
该参数的类型结构为:
{
asPath?: string;
pathname?: string;
query?: Record<string, string>;
segments?: (string | [string, string])[];
}
- 默认未设置时,框架使用的默认导航上下文为
{ pathname: '/', query: {} },nextjs.navigation.segments的默认值为[]; push()、replace()等useRouter()方法会被替换为可被 Mock API 断言与操纵的 mock 函数。
例如在 Meta 中通过 segments 模拟 layout segment(完整示例见 docs/_snippets/nextjs-navigation-segments-override-in-meta.md):
// NavigationBasedComponent 将读到:
// useSelectedLayoutSegment() -> 'dashboard'
// useSelectedLayoutSegments() -> ['dashboard', 'analytics']
// useParams() -> {}
parameters: {
nextjs: {
appDirectory: true,
navigation: {
segments: ['dashboard', 'analytics'],
},
},
}
若组件用到了 useParams(),需把 segments 元素写成 [key, value] 二元组(见 docs/_snippets/nextjs-navigation-segments-for-use-params-override-in-meta.md):
// 此时:
// useSelectedLayoutSegment() -> 'hello'
// useSelectedLayoutSegments() -> ['hello', 'nextjs']
// useParams() -> { slug: 'hello', framework: 'nextjs' }
parameters: {
nextjs: {
appDirectory: true,
navigation: {
segments: [
['slug', 'hello'],
['framework', 'nextjs'],
],
},
},
}
在源码层面,AppRouterProvider 的 pathParams 正是这样解析二元组 segments 的(code/frameworks/nextjs/src/routing/app-router-provider.tsx):仅当元素是长度为 2 的数组且首项为字符串时,才把 [key, value] 写入 params。
同时框架还提供 @storybook/nextjs/navigation.mock 这一具名 Mock 模块,便于在 story 的 play function 中直接断言导航调用;其类型为 typeof import('next/navigation') & { getRouter(): ... },getRouter() 返回可操纵的 Mock router 对象(docs/get-started/frameworks/nextjs.mdx)。
常见误区与建议
- 不要混淆
nextjs.router与nextjs.navigation:router作用于 Page Router 的next/router(此时无需appDirectory),而navigation只对 App Router 的next/navigation生效,前提是appDirectory: true。两者类型都接近Partial<NextRouter>,但作用于完全不同的 Context 树。 - Story 同时包含两类组件怎么办:App Router 与 Page Router 上下文互斥,Storybook 会在一个 Preview 中二选一注入。若必须同屏渲染依赖不同路由模型的组件,建议把它们拆到不同的 Stories 中,分别用对应作用域的参数声明,再通过 Composition 等方式组合展示。
- 全局开启需谨慎:把
appDirectory写进.storybook/preview会对项目内全部 Stories 生效。若项目仍保留少量pages/目录下的页面/组件 Story,优先采用 Meta 级或 Story 级覆盖,保持参数继承语义清晰(详情可查阅 Parameters 继承文档)。 - 类型提示:使用 TypeScript 时,建议 import 自
@storybook/nextjs或@storybook/nextjs-vite(即替换代码注释中的your-framework),Meta/StoryObj/Preview泛型会自动带上nextjs.appDirectory的完整类型约束。
小结
nextjs.appDirectory 是 Storybook Next.js 框架中「一套参数切换两套路由运行时」的关键开关:为 true 时,Story 由 AppRouterProvider 包裹、Loader 改为 Mock next/navigation,使依赖 App Router Hooks 的组件可在隔离环境中稳定渲染并接受测试断言;为默认 false 时则走 next/router 的 Page Router 模拟。结合 nextjs.navigation.segments 等配套参数,你可以在 Story 中精确复现任意路径、layout segment 与动态参数,让组件测试与真实 Next.js 运行时的行为保持一致。需要继续探索时,可阅读 Storybook for Next.js (Webpack) 官方指南 与 Next.js Vite 指南 的 Navigation 章节,或直接查看上述源码文件中的实现细节。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0631
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
video-shotcraftAI宣传片skill,使用 Remotion 制作电影级产品视频:提供106 张镜头配方卡和可复用的视频魔板。适用于 Claude Code 与 Codex以及所有其他智能体Markdown00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python09
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00