The gap between writing a component and being confident it works across all its states — loading, empty, error, mobile, dark mode — is where most UI bugs live. Storybook 8 closes that gap by letting you develop and test components in complete isolation, with first-class support for Next.js App Router and React Server Components.
Installation
npx storybook@latest init
The init command detects Next.js automatically and installs @storybook/nextjs — which handles next/image, next/link, next/navigation hooks, and static file serving automatically.
npm run storybook
Story Format: CSF3
// components/ui/Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react'
import { Button } from './Button'
const meta: Meta<typeof Button> = {
title: 'UI/Button',
component: Button,
tags: ['autodocs'], // generates documentation page automatically
args: {
children: 'Click me',
},
argTypes: {
variant: {
control: 'select',
options: ['default', 'destructive', 'outline', 'ghost'],
},
size: {
control: 'radio',
options: ['sm', 'default', 'lg'],
},
},
}
export default meta
type Story = StoryObj<typeof Button>
export const Default: Story = {}
export const Destructive: Story = {
args: { variant: 'destructive', children: 'Delete account' },
}
export const Loading: Story = {
args: { disabled: true, children: 'Saving...' },
}
Decorators: Providers for Every Story
// .storybook/preview.ts
import type { Preview } from '@storybook/react'
import { ThemeProvider } from '../src/components/theme-provider'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import '../src/app/globals.css'
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const preview: Preview = {
decorators: [
(Story) => (
<QueryClientProvider client={queryClient}>
<ThemeProvider defaultTheme="dark">
<Story />
</ThemeProvider>
</QueryClientProvider>
),
],
parameters: {
backgrounds: {
default: 'dark',
values: [
{ name: 'dark', value: '#0D1117' },
{ name: 'light', value: '#ffffff' },
],
},
layout: 'centered',
},
}
export default preview
The play Function: Interaction Testing
The play function runs after a story renders, simulates interactions, and asserts on results:
// components/forms/LoginForm.stories.tsx
import { expect, userEvent, within, fn } from '@storybook/test'
import { LoginForm } from './LoginForm'
const meta: Meta<typeof LoginForm> = {
component: LoginForm,
args: { onSubmit: fn() },
}
export default meta
type Story = StoryObj<typeof LoginForm>
export const ValidSubmission: Story = {
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement)
await userEvent.type(canvas.getByLabelText('Email'), 'alice@example.com')
await userEvent.type(canvas.getByLabelText('Password'), 'correcthorse')
await userEvent.click(canvas.getByRole('button', { name: /sign in/i }))
await expect(args.onSubmit).toHaveBeenCalledWith({
email: 'alice@example.com',
password: 'correcthorse',
})
},
}
export const ValidationErrors: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
await userEvent.click(canvas.getByRole('button', { name: /sign in/i }))
await expect(canvas.getByText('Email is required')).toBeInTheDocument()
await expect(canvas.getByText('Password is required')).toBeInTheDocument()
},
}
Running Interaction Tests in CI
npm install --save-dev @storybook/test-runner
# .github/workflows/storybook.yml
- name: Build Storybook
run: npm run build-storybook
- name: Run interaction tests
run: |
npx concurrently -k -s first \
"npx http-server storybook-static --port 6006 --silent" \
"npx wait-on tcp:6006 && npm run test-storybook"
Mocking Next.js Navigation
const meta: Meta<typeof Breadcrumb> = {
component: Breadcrumb,
parameters: {
nextjs: {
appDirectory: true,
navigation: {
pathname: '/dashboard/users',
},
},
},
}
// Per-story override:
export const NestedPage: Story = {
parameters: {
nextjs: {
navigation: { pathname: '/dashboard/users/123/edit' },
},
},
}
useRouter, useParams, useSearchParams — all available through parameters.nextjs.navigation.
Mocking Server Actions
import { fn } from '@storybook/test'
import { TodoItem } from './TodoItem'
export default {
component: TodoItem,
args: {
onToggle: fn(),
onDelete: fn(),
},
}
export const DeleteFlow: Story = {
args: {
todo: { id: '1', title: 'Old task', completed: false },
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement)
await userEvent.click(canvas.getByRole('button', { name: /delete/i }))
await expect(args.onDelete).toHaveBeenCalledWith('1')
},
}
React Server Component Stories
// UserProfile.stories.tsx (RSC with mocked DB)
vi.mock('@/lib/db', () => ({
db: {
query: {
users: { findFirst: vi.fn() },
},
},
}))
export const WithUser: Story = {
args: { userId: 'user_123' },
beforeEach() {
const { db } = require('@/lib/db')
db.query.users.findFirst.mockResolvedValue({
id: 'user_123',
name: 'Alice Chen',
email: 'alice@example.com',
})
},
}
export const UserNotFound: Story = {
args: { userId: 'unknown' },
beforeEach() {
const { db } = require('@/lib/db')
db.query.users.findFirst.mockResolvedValue(null)
},
}
Viewport Testing
export const Mobile: Story = {
parameters: { viewport: { defaultViewport: 'mobile1' } },
}
export const Tablet: Story = {
parameters: { viewport: { defaultViewport: 'tablet' } },
}
Accessibility Testing
npm install --save-dev @storybook/addon-a11y
// .storybook/main.ts
addons: ['@storybook/addon-essentials', '@storybook/addon-a11y'],
Every story now has an Accessibility tab with axe violations. Assert on it in play:
import { checkA11y } from '@storybook/addon-a11y'
export const Accessible: Story = {
play: async ({ canvasElement }) => {
// ...interactions
await checkA11y(canvasElement)
},
}
Story Organization at Scale
src/components/
ui/
Button.stories.tsx → title: 'UI/Button'
Input.stories.tsx → title: 'UI/Input'
features/
users/
UserCard.stories.tsx → title: 'Features/Users/UserCard'
billing/
PlanCard.stories.tsx → title: 'Features/Billing/PlanCard'
tags: ['autodocs'] generates a documentation page with all stories, controls, and prop types automatically — no maintenance required.
Quick Reference
// Story structure
const meta: Meta<typeof Component> = {
component: Component,
tags: ['autodocs'],
args: { /* shared defaults */ },
argTypes: { variant: { control: 'select', options: [...] } },
decorators: [(Story) => <Provider><Story /></Provider>],
}
export default meta
type Story = StoryObj<typeof Component>
export const MyStory: Story = { args: { ... } }
// play function imports
import { expect, userEvent, within, fn } from '@storybook/test'
// play function
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement)
await userEvent.type(canvas.getByLabelText('Email'), 'test@test.com')
await userEvent.click(canvas.getByRole('button', { name: /submit/i }))
await expect(args.onSubmit).toHaveBeenCalled()
}
// Next.js mock
parameters: { nextjs: { navigation: { pathname: '/dashboard' } } }
// Run CI tests
npx test-storybook
The workflow: for every component, write stories for the empty state, loading state, error state, and main success state before writing the component itself. It forces you to think through edge cases upfront, and you get visual regression tests as a side effect.
Full article at stacknotice.com/blog/storybook-nextjs-guide-2026
Top comments (0)