React
|stacknotice.com
14 min left|
0%
|2,800 words
React

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

Set up Storybook 8 with Next.js App Router — CSF3 stories, play function interaction tests, decorators for providers, RSC mocking, visual testing, and CI integration.

C
Carlos Oliva
Software Developer
July 14, 202614 min read
Share:
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 built in.

This guide covers everything from initial setup through interaction testing, provider decorators, RSC mocking, and visual regression in CI.

Installation with Next.js

npx storybook@latest init

The init command detects Next.js automatically and installs @storybook/nextjs — an adapter that handles Next.js-specific concerns: next/image, next/link, next/navigation hooks, environment variables, and static file serving. No manual configuration required for most projects.

After installation you get:

  • .storybook/main.ts — framework config and addons
  • .storybook/preview.ts — global decorators and parameters
  • Example stories in src/stories/

Start the dev server:

npm run storybook

Story Format: CSF3

Stories use Component Story Format 3 (CSF3). Each story is a named export from a file ending in .stories.tsx:

// 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',  // shared default across all stories
  },
  argTypes: {
    variant: {
      control: 'select',
      options: ['default', 'destructive', 'outline', 'ghost'],
    },
    size: {
      control: 'radio',
      options: ['sm', 'default', 'lg'],
    },
    disabled: {
      control: 'boolean',
    },
  },
}
 
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...' },
}
 
export const IconWithLabel: Story = {
  args: {
    children: (
      <>
        <PlusCircle className="mr-2 h-4 w-4" />
        Add item
      </>
    ),
  },
}

args defines the props. argTypes controls how the Storybook UI lets you interact with them — dropdowns, toggles, text inputs in the controls panel.

Decorators: Wrapping Stories with Providers

Real components need context — theme, auth session, React Query client, toast provider. Decorators wrap every story in your providers:

// .storybook/preview.ts
import type { Preview } from '@storybook/react'
import { ThemeProvider } from '../src/components/theme-provider'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { Toaster } from '../src/components/ui/sonner'
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 />
          <Toaster />
        </ThemeProvider>
      </QueryClientProvider>
    ),
  ],
  parameters: {
    backgrounds: {
      default: 'dark',
      values: [
        { name: 'dark', value: '#0D1117' },
        { name: 'light', value: '#ffffff' },
      ],
    },
    layout: 'centered',
  },
}
 
export default preview

You can also add decorators per-story or per-component:

// Component-level decorator (only this component's stories)
const meta: Meta<typeof UserCard> = {
  decorators: [
    (Story) => (
      <div className="p-8 max-w-sm">
        <Story />
      </div>
    ),
  ],
}
 
// Story-level decorator (only this story)
export const InSidebar: Story = {
  decorators: [
    (Story) => (
      <div className="w-64 bg-sidebar p-4">
        <Story />
      </div>
    ),
  ],
}

The play Function: Interaction Testing

The play function runs after a story renders. It simulates user interactions and asserts on the result — without a separate test file:

// components/forms/LoginForm.stories.tsx
import type { Meta, StoryObj } from '@storybook/react'
import { expect, userEvent, within, fn } from '@storybook/test'
import { LoginForm } from './LoginForm'
 
const meta: Meta<typeof LoginForm> = {
  component: LoginForm,
  args: {
    onSubmit: fn(),  // a tracked mock function
  },
}
 
export default meta
type Story = StoryObj<typeof LoginForm>
 
export const ValidSubmission: Story = {
  play: async ({ canvasElement, args }) => {
    const canvas = within(canvasElement)
 
    // Find and fill the email input
    await userEvent.type(
      canvas.getByLabelText('Email'),
      'alice@example.com'
    )
 
    // Fill the password input
    await userEvent.type(
      canvas.getByLabelText('Password'),
      'correcthorse'
    )
 
    // Submit the form
    await userEvent.click(canvas.getByRole('button', { name: /sign in/i }))
 
    // Assert the onSubmit handler was called with the right data
    await expect(args.onSubmit).toHaveBeenCalledWith({
      email: 'alice@example.com',
      password: 'correcthorse',
    })
  },
}
 
export const ValidationErrors: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement)
 
    // Submit without filling anything
    await userEvent.click(canvas.getByRole('button', { name: /sign in/i }))
 
    // Assert validation messages appear
    await expect(canvas.getByText('Email is required')).toBeInTheDocument()
    await expect(canvas.getByText('Password is required')).toBeInTheDocument()
  },
}
 
export const InvalidEmail: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement)
 
    await userEvent.type(canvas.getByLabelText('Email'), 'not-an-email')
    await userEvent.tab()  // trigger blur validation
 
    await expect(
      canvas.getByText('Please enter a valid email address')
    ).toBeInTheDocument()
  },
}

You watch these interactions run live in the Storybook UI. In CI, the Storybook test runner executes them headlessly.

Running Interaction Tests in CI

# Install the test runner
npm install --save-dev @storybook/test-runner
 
# Add to package.json scripts:
# "test-storybook": "test-storybook"
# .github/workflows/storybook.yml
- name: Build Storybook
  run: npm run build-storybook
 
- name: Serve Storybook and run tests
  run: |
    npx concurrently -k -s first -n "SB,TEST" -c "magenta,blue" \
      "npx http-server storybook-static --port 6006 --silent" \
      "npx wait-on tcp:6006 && npm run test-storybook"

Mocking Next.js Navigation

@storybook/nextjs mocks next/navigation hooks automatically, but you need to configure the mock values for stories that read them:

// components/nav/Breadcrumb.stories.tsx
import type { Meta, StoryObj } from '@storybook/react'
import { Breadcrumb } from './Breadcrumb'
 
const meta: Meta<typeof Breadcrumb> = {
  component: Breadcrumb,
  parameters: {
    nextjs: {
      // Mock the App Router context
      appDirectory: true,
      navigation: {
        pathname: '/dashboard/users',
        segments: [['dashboard', ''], ['users', '']],
      },
    },
  },
}
 
export default meta
type Story = StoryObj<typeof Breadcrumb>
 
export const UsersPage: Story = {}
 
export const NestedPage: Story = {
  parameters: {
    nextjs: {
      navigation: {
        pathname: '/dashboard/users/123/edit',
      },
    },
  },
}

For useRouter, useParams, useSearchParams — all available through the same parameters.nextjs.navigation config.

Mocking Server Actions and API Calls

Use fn() from @storybook/test to mock server actions passed as props:

// components/todos/TodoItem.stories.tsx
import { fn } from '@storybook/test'
import { TodoItem } from './TodoItem'
 
export default {
  component: TodoItem,
  args: {
    onToggle: fn(),
    onDelete: fn(),
  },
}
 
export const Completed: Story = {
  args: {
    todo: { id: '1', title: 'Write Storybook stories', completed: true },
  },
}
 
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')
  },
}

For components that call a server action directly (not via prop), mock the module:

// The component imports the action directly
import { deleteTodo } from '@/actions/todos'
 
// In the story file:
import { deleteTodo } from '@/actions/todos'
import { fn } from '@storybook/test'
 
// Mock the entire module
export default {
  component: TodoItem,
  beforeEach() {
    // Reset mock between stories
    (deleteTodo as ReturnType<typeof fn>).mockResolvedValue({ success: true })
  },
}

Configure the mock in .storybook/main.ts:

// .storybook/main.ts
const config: StorybookConfig = {
  framework: '@storybook/nextjs',
  stories: ['../src/**/*.stories.@(js|jsx|ts|tsx)'],
  addons: ['@storybook/addon-essentials', '@storybook/addon-interactions'],
  // Enable module mocking
  experimental_indexers: [],
}

React Server Component Stories

Storybook 8 can render RSCs by mocking the server modules they depend on:

// components/UserProfile.tsx (Server Component)
import { db } from '@/lib/db'
import { auth } from '@/lib/auth'
 
export async function UserProfile({ userId }: { userId: string }) {
  const user = await db.query.users.findFirst({ where: eq(users.id, userId) })
  return <div>{user?.name}</div>
}
// components/UserProfile.stories.tsx
import type { Meta, StoryObj } from '@storybook/react'
import { UserProfile } from './UserProfile'
 
// Mock the server modules
vi.mock('@/lib/db', () => ({
  db: {
    query: {
      users: {
        findFirst: vi.fn(),
      },
    },
  },
}))
 
const meta: Meta<typeof UserProfile> = {
  component: UserProfile,
}
 
export default meta
type Story = StoryObj<typeof UserProfile>
 
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

Test responsive behavior without resizing your browser window:

export const Mobile: Story = {
  parameters: {
    viewport: {
      defaultViewport: 'mobile1',  // 320px
    },
  },
}
 
export const Tablet: Story = {
  parameters: {
    viewport: {
      defaultViewport: 'tablet',  // 768px
    },
  },
}

Define custom viewports in preview.ts:

parameters: {
  viewport: {
    viewports: {
      dashboardSidebar: { name: 'Dashboard sidebar', styles: { width: '260px', height: '900px' } },
      productCard: { name: 'Product grid card', styles: { width: '320px', height: '480px' } },
    },
  },
},

Accessibility Testing

The a11y addon runs axe on every story automatically:

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

Now every story has an Accessibility tab showing violations. You can also assert on it in play:

import { checkA11y } from '@storybook/addon-a11y'
 
export const AccessibleForm: Story = {
  play: async ({ canvasElement }) => {
    // ... render and interact
    await checkA11y(canvasElement)
  },
}

Organizing Stories at Scale

For a large component library, folder structure matters:

src/
  components/
    ui/
      Button.stories.tsx
      Input.stories.tsx
      Modal.stories.tsx
    features/
      users/
        UserCard.stories.tsx
        UserTable.stories.tsx
      billing/
        PlanCard.stories.tsx
        InvoiceRow.stories.tsx

Use the title field in meta to group them in the sidebar:

// Groups under "Features/Users/"
const meta: Meta<typeof UserCard> = {
  title: 'Features/Users/UserCard',
  component: UserCard,
}
 
// Groups under "UI/"
const meta: Meta<typeof Button> = {
  title: 'UI/Button',
  component: Button,
  tags: ['autodocs'],
}

The autodocs tag generates a documentation page with all stories, controls, and prop types automatically.

Quick Reference

// .storybook/main.ts — Next.js framework
framework: '@storybook/nextjs'
 
// 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
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 navigation mock
parameters: { nextjs: { navigation: { pathname: '/dashboard' } } }
 
// Mock functions
args: { onSubmit: fn() }  // fn() from '@storybook/test'
 
// Run tests
npx test-storybook

The workflow that earns its keep: for every component, write stories for the empty state, loading state, error state, and the main success state before writing the component itself. It forces you to think through all the edge cases up front, and you get free visual regression tests as a side effect. Pairs well with Vitest + Playwright for the layers of testing that Storybook doesn't cover — integration tests, API routes, and full E2E flows.

#react#nextjs#testing#storybook#typescript
Share:
C
Carlos Oliva
Software Developer · stacknotice.com

Software developer with hands-on experience building production apps with React, Next.js, Angular, TypeScript, and Spring Boot. I write practical guides on Claude Code, AI tools, and modern web development — covering the decisions and trade-offs that senior-level tutorials actually explain.

More about Carlos

Enjoyed this article?

Get weekly insights on Claude Code, React, and AI tools — practical guides for developers who build real things.

No spam. Unsubscribe anytime. By subscribing you agree to our Privacy Policy.