How to deploy Storybook alongside NextJS

Storybook, in my opinion, is an excellent tool to visually document your React components. There is one problem that we were facing: how to deploy Storybook so that our clients can use?

Here are the tech stack that we currently have:

  • Frontend components is built using Next 12.

  • Assets (fonts, images) are fetched from a private CMS (in this case, AEM), which requires username/password to authenticate.

  • Data is fetched as a JSON document from AEM using credentials provided in the Environment Variable.

Possible solutions:

We can either:

  1. Deploy Storybook separately from NextJS, which includes building and running a http-server instance serving the generated static content. This is the way that both Storybook and Vercel recommended (see Publishing Storybook & Deploying Storybook with Vercel).

  2. Build Storybook into NextJS's public folder, which can be served statically alongside NextJS.

Let's examine each solutions in detail.

Deploy Storybook separately:

Pros:

  • Out-of-the-box support, which means it can be up and running quickly.

Cons:

  • Having to create a separate Vercel project and add a sub-domain pointing to the new Storybook instance. Which means more stakeholders are getting involved and more infrastructure to manage.

  • Having to expose the credentials to the FE so that assets can be retrieved from the CMS.

Deploy Storybook alongside NextJS:

Assuming that you have the NextJS deployment pipeline ready, the steps to deploy Storybook are as followed:

  1. Setup storybook's output directory to public folder.

  // package.json
   {
     "scripts": {
       //...
       "build-storybook": "storybook build -o ./public/storybook"
     },
   }
  1. Change build script to include storybook's build command.

// package.json
   {
     "scripts": {
       //...
       "build-storybook": "storybook build -o ./public/storybook",
       "build": "npm run build-storybook && npm run build"
     },
   }
  1. (Optional) Create a storybook page /storybook to redirect to /storybook/index.html. By default, storybook will be builds to storybook/index.html. Having a /storybook fits in better with other NextJS's pages.

  // src/pages/storybook.tsx
   import {useEffect} from "react";

   /** Manually redirect to /storybook/index.html as **rewrites** doesn't work with static storybook pages */

   export default function Storybook() {
     useEffect(() => {
       window.location.href = '/storybook/index.html';
     }, [])

     return null;
   }
  1. Run npm run build and npm run start. You should be able to navigate to localhost:3000/storybook to view the deployed version of storybook.

Deploying the code to Vercel works...mostly. The assets cannot be fetched even though the Environment Variables are correctly set. We'll talk about this next.

Challenges

There are 2 roadblocks that I faced:

1. NextConfig.rewrites doesn't work on static pages. This is because the static page sits outside of the NextJS app.

=> A manual redirect using pages/storybook works wonderfully.

2. Static page cannot pick up environment variables... That's why they are static pages.

=> We'll have to route all the API requests to get assets via a NextJS API, through which we are free to manipulate the requests as we see fit.

To get started, created a catch-all API route (e.g. src/api/content/[[…slug]].ts). This API will catch all the requests started with /content, retrieve the content from our CMS and forward that back to the Frontend.

// src/api/content/[[…slug]].ts

function removeCred(str: string) {
  if (!str) return str;
  const urlRegex = /https?:\/\/[^ "\s]+/g;
  return str.replace(urlRegex, (urlMatch) => {
    try {
      let url = new URL(urlMatch);

      url.username = '';
      url.password = '';

      return url.toString();
    } catch (error) {
      return urlMatch;
    }
  });
}

async function GET(req: NextApiRequest, res: NextApiResponse) {
  if (req.method !== 'GET') {
    return res.status(405).send('Only GET method is allowed');
  }

  // Remove **api** from the request URL to convert it back to the original CMS URL.
  const url = req.url.replace('/api', '');
  const hostNameWithCredentials = process.env.host;
  const hostNameWithCredentialsRemoved = removeCred(process.env.host);

  const requestURL = new URL(`${hostNameWithCredentialsRemoved }${url}`);
  const {username, password} = new URL(hostNameWithCredentials);

  const response = await fetch(requestURL, {
    headers: {
      Authorization: `Basic ${btoa(`${username}:${password}`)}`,
    }
  });

  try {
    // Forward the original headers, including Content-Type and Content-Length
    const headers = Object.fromEntries(response.headers.entries());
    Object.entries(headers).forEach(([key, value]) => {
      res.setHeader(key, value);
    })
    const buffer = await response.arrayBuffer();
    const payload = Buffer.from(buffer);
    res.status(200).send(payload);
  } catch (e) {
    console.error(e);
    res.status(500).send({error: e});
  }
}

export default GET;

Then, prepends /api to all font requests to route them through our newly created API (You may need to use FontFace for this).

// const font_name = new FontFace('Font Name', `url('/content/Fira-Sans.ttf')`);
const font_name = new FontFace('Font Name', `url('/api/content/Fira-Sans.ttf')`);

document.fonts.add(font_name);

That’s it, the 2 issues have been resolved and our storybook is now live alongside our NextJS website!