DEV Community

Carlos Oliva Pascual
Carlos Oliva Pascual

Posted on • Originally published at stacknotice.com

Storybook 8 with Next.js: Complete Guide (2026)

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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...' },
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
  },
}
Enter fullscreen mode Exit fullscreen mode

Running Interaction Tests in CI

npm install --save-dev @storybook/test-runner
Enter fullscreen mode Exit fullscreen mode
# .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"
Enter fullscreen mode Exit fullscreen mode

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' },
    },
  },
}
Enter fullscreen mode Exit fullscreen mode

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')
  },
}
Enter fullscreen mode Exit fullscreen mode

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)
  },
}
Enter fullscreen mode Exit fullscreen mode

Viewport Testing

export const Mobile: Story = {
  parameters: { viewport: { defaultViewport: 'mobile1' } },
}

export const Tablet: Story = {
  parameters: { viewport: { defaultViewport: 'tablet' } },
}
Enter fullscreen mode Exit fullscreen mode

Accessibility Testing

npm install --save-dev @storybook/addon-a11y
Enter fullscreen mode Exit fullscreen mode
// .storybook/main.ts
addons: ['@storybook/addon-essentials', '@storybook/addon-a11y'],
Enter fullscreen mode Exit fullscreen mode

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)
  },
}
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)