Back to blog

Build a Next.js app in Storybook with React Server Components and Mock Service Worker

Develop, document, and test RSC applications in isolation, using MSW to mock network requests

loading
Michael Shilman
@mshilman
Last updated:

Storybook 8 (our next major release) brings React Server Component (RSC) compatibility to Storybook for the very first time, letting you build, test and document Next.js server applications in isolation.

In our first demo, we used Storybook to develop a contact card RSC, which accessed contact data both asynchronously and from a file system while simulating server code through module mocks.

A Storybook story showing a contact card for Chuck Norris
When Chuck Norris builds frontend applications, the UI tests itself!

Next, we’ll explore how to build an entire app in isolation with the Next.js App Router, by rebuilding the Hacker Next example in Storybook with the help of Mock Service Worker.

Why build pages in isolation?

It’s amazing how much UI fits into just two pages. Consider the data states your pages need. Then, multiply them by responsive layouts, logged-in views, themes, browsers, locales, and accessibility. It doesn’t take much for a handful of pages to become hundreds of variants.

Storybook solves this complexity by letting you teleport to any UI state by isolating it as a story! If you’re new to Storybook, here’s how stories work.

Interested in taking your Storybook tests even further? Storybook 8 now supports native automated visual tests, so you can catch unexpected visual changes across your entire application in a single button click. Learn more about getting started with the Visual Tests addon.

Writing stories for Hacker Next

First, install Storybook in your Next.js project:

npx storybook@next init

Then, add the experimentalRSC flag to Storybook’s main.ts, and point it to the new stories we’re about to write:

// main.ts

const config: StorybookConfig = {
- stories: ['../stories/**/*.mdx', '../stories/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
+ stories: ['../app/**/*.stories.tsx'],
  // ... existing config
+ features: { experimentalRSC: true }
}

Now, let’s write stories for Hacker Next’s two components: the news homepage and the item page! Here’s what a simple story could look like for the news page:

// app/news/[page]/index.stories.tsx

import type { Meta, StoryObj } from '@storybook/react';
import News from './page';

const meta = {
  title: 'app/News',
  component: News,
} satisfies Meta<typeof News>;
export default meta;

type Story = StoryObj<typeof meta>;

export const Home: Story = {
  args: { params: { page: 1 } },
}
Hacker Next, but there’s no styling :(

Though this works, you’ll notice that it’s missing styling. We can fix that by adding a decorator to our .storybook/preview.tsx:

// .storybook/preview.tsx

import Layout from '../app/layout.tsx';

export default {
  // other settings
  decorators: [(Story) => <Layout><Story /></Layout>],
}
Hacker Next, with styling added back in

That’s more like it! Now, try doing this for app/item/[id]/(comments)/page.tsx. If you get stuck, check our repo.

Mock ‘n’ roll with Mock Service Worker

Rather than using real data, we want to be able to control the data. This lets us test different states and generate consistent results.

Hacker Next fetches data from a network API, so we’ll mock its requests with Mock Service Worker (MSW).

💡
If you follow the space closely, you might ask, ‘Isn’t MSW currently incompatible with Next.js app directory?’ This is true. However, since we’re running it in the browser rather than in Next.js, there’s no problem using MSW with Storybook.

First, let’s add Storybook’s MSW addon to our project. We’ll use the canary version that supports MSW 2.0’s dramatically improved API.

pnpx storybook add msw-storybook-addon@2.0.0--canary.122.06f0c92.0
pnpx msw init public

Next, update .storybook/preview.tsx to initialize MSW with the onUnhandledRequest option. This ensures our existing story continues to work.

// .storybook/preview.tsx

// ... existing imports
+ import { initialize, mswLoader } from 'msw-storybook-addon';

+ initialize({ onUnhandledRequest: 'warn' });

const preview: Preview = {
+ loaders: [mswLoader],
  decorators: [(Story) => <Layout><Story /></Layout>],
}

Now, let’s create a story for Hacker Next’s homepage, featuring a single post:

// app/news/[page]/index.stories.tsx
import { http, HttpResponse } from 'msw'

// ...existing meta/story

export const Mocked = {
  ...Home,
  parameters: {
    msw: {
      handlers: [
        http.get('https://hacker-news.firebaseio.com/v0/topstories.json', () => {
          return HttpResponse.json([1]);
        }),
        http.get('https://hacker-news.firebaseio.com/v0/item/1.json', () => {
          return HttpResponse.json({
            id: 1,
            time: Date.now(),
            user: 'shilman',
            url: 'http://storybook.js.org',
            title: 'Storybook + Next.js = ❤️',
            score: 999,
          });
        }),
      ],
    },
  },
};

By mocking two REST API requests from the frontend and hard-coding the responses, we get the following story:

A Storybook story for a Hacker Next detail page, titled ‘Storybook + Next.js = ❤️’

MSW data factories

Hard-coded API responses are difficult to scale. So, let’s write a story that controls page content with a higher level argument! We’ll need to:

  1. Build a simplified in-memory database
  2. Create MSW handlers that read from the database and generate the desired network responses
  3. Write stories to populate the database with test cases

Step 1: Build the database

First, let’s create the database using @mswjs/data (MSW’s data factory library) and Faker.js.

// data.mock.ts

import { faker } from '@faker-js/faker'
import { drop, factory, primaryKey } from '@mswjs/data

let _id;
const db = factory({
  item: {
    id: primaryKey(() => _id++),
    time: () => faker.date.recent({ days: 2 }).getTime() / 1000,
    user: faker.internet.userName,
    title: faker.lorem.words,
    url: faker.internet.url,
    score: () => faker.number.int(100),
  }
})

/** Reset the database */
export const reset = (seed?: number) => {
  _id = 1
  faker.seed(seed ?? 123)
  return drop(db)
}

/** Create a post. Faker will fill in any missing data */
export const createPost = (item = {}) => db.item.create(item);

/** Utility function */
export const range = (n: number) => Array.from({length: n}, (x, i) => i);

/** Return all the post IDs */
export const postIds = () => db.item.findMany({}).map((p) => p.id);

/** Return the content of a single post by ID */
export const getItem = (id: number) => db.item.findFirst({ where: { id: { equals: id }}});

This lets you specify the posts exactly as you want them to appear. When we leave any data unspecified, Faker fills in the gaps. This way, you can create tens or hundreds of posts with minimal code!

Step 2: Create MSW handlers

Next, we’ll update .storybook/preview.tsx with MSW handlers that read from the database. These handlers are available across all your stories and read whatever’s in the database. This means a story’s only job is to fill the database with useful data!

// .storybook/preview.tsx

import { postIds, getItem } from '../lib/data.mock.ts';
import { http, HttpResponse } from 'msw'

const preview: Preview = {
  // ...existing configuration
  parameters: { msw: { handlers: [
    http.get(
      'https://hacker-news.firebaseio.com/v0/topstories.json',
      () => HttpResponse.json(postIds())
    ),
    http.get<{ id: string }>(
      'https://hacker-news.firebaseio.com/v0/item/:id.json',
      ({ params }) => HttpResponse.json(getItem(parseInt(params.id, 10)))
    )
  ] } },
};

Step 3: Write stories

Finally, we’ll write stories for our new setup.

First, replace your existing Mocked story with a new version using a loader (a function that runs before a story renders). This loader calls our createPost helper function, which 1) instantiates a post and 2) adds it to the in-memory database.

// app/news/[page]/index.stories.tsx

import { createPost } from '../../../lib/data.mock';

// ...existing meta/story

export const MockedNew = {
  ...Home,
  loaders: [() => {
    createPost({
      id: -1,
      user: 'shilman',
      url: 'http://storybook.js.org',
      title: 'Storybook + Next.js = ❤️',
      score: 999,
    });
  }],
};

This scheme really shines when you need to create a lot of data at once. To demonstrate this, let’s create a homepage showing 30 posts. And to make it even more powerful, we can allow the number of posts to be controlled interactively in Storybook’s UI:

// app/news/[page]/index.stories.tsx

import { createPost, range, reset  } from '../../../lib/data.mock'

export const FullPage = {
  args: {
    postCount: 30,
  },
  loaders: [({ args: { postCount } }) => {
    reset();
    range(postCount).forEach(() => createPost());
  }];
}
A Hacker Next feed featuring 30 posts

It’s time to test

Congratulations! You’ve built Hacker Next in Storybook with data that you can customize for different tests. Alternatively, view a demo Storybook (shared via Chromatic) or our repo.

0:00
/0:21

As well as bringing your UI into one place, you’re able to test Hacker Next in ways you couldn’t otherwise.

For example, you can write stories for Hacker Next’s upvote and collapsing comment states by using Storybook’s play function. This is a code snippet that simulates user interactions and runs immediately after a story renders. It can interact with the DOM using Testing-Library and assert using Vitest’s expect and spies.

Here’s a story that uses a play function to upvote the first post on the homepage:

0:00
/0:13
// app/news/[page]/index.stories.tsx

import { within, userEvent } from '@storybook/test';

export const Upvoted: Story = {
  ...FullPage,
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement)
    const votes = await canvas.findAllByText('▲')
    await userEvent.click(votes[0])
  }
}

Bringing it all together

In this exercise, we’ve cataloged the key UI states of a Next.js application. Once we have this all in Storybook, we can:

  1. Develop against mocked data even if our backend is under development
  2. Develop hard-to-reach UI states such as a ‘credit card expired’ screen
  3. Instantly run visual regression and a11y tests on every screen, testing across browsers and different resolutions
  4. View production stories next to their design files to ensure smooth handoff
  5. Onboard new developers with living and comprehensive documentation of the entire frontend architecture
  6. Learn more about using Next.js with Storybook

Storybook revolutionized the development of reusable components. Now, you can apply those same benefits to the pages of your applications.

In our next RSC post, we’ll explore module mocking to handle real-world cases where it’s impossible or impractical to mock network requests.

Credits

Thanks to Artem Zakharchenko (MSW’s core maintainer) and the Next.js team for their review and guidance!

Join the Storybook mailing list

Get the latest news, updates and releases

6,615 developers and counting

We’re hiring!

Join the team behind Storybook and Chromatic. Build tools that are used in production by 100s of thousands of developers. Remote-first.

View jobs

Popular posts

React Native Storybook 7

Aligning React Native and core Storybook more closely than ever before
loading
Daniel Williams

Storybook 8 Beta

Major compatibility & performance improvements
loading
Michael Shilman

Future of Storybook in 2024

Highlights from 2023 and what’s coming next
loading
Michael Shilman
Join the community
6,615 developers and counting
WhyWhy StorybookComponent-driven UI
DocsGuidesTutorialsChangelogTelemetry
CommunityAddonsGet involvedBlog
ShowcaseExploreProjectsComponent glossary
Open source software
Storybook

Maintained by
Chromatic
Special thanks to Netlify and CircleCI