🍡

Yarn v4を使って Next.js 14 + storybook v7 + styled-components のボイラープレートを作る

2023/12/08に公開

この記事の趣旨

この記事はコネヒトアドベントカレンダー2023の3日目のコンテンツです。
Next.jsの個人開発をする時、これまでは Tailwind CSS をよく使っていたのですが、社内のフロントエンドではstorybook + styled-componentsでコンポーネントを構築しているケースが多かったため、10月末にリリースされたYarn v4の素振りも兼ねてボイラープレートを作ろうと思ったのがきっかけです。

セットアップ: Next.js のリポジトリを用意する

今回は TypeScript を使用し、appディレクトリで開発を行いたかったため以下のように Next.js のセットアップを行いました。

zsh
❯ npx create-next-app@latest         
✔ What is your project named? … nextjs-styledcomponent-sb
✔ Would you like to use TypeScript? … Yes
✔ Would you like to use ESLint? … Yes
✔ Would you like to use Tailwind CSS? … No
✔ Would you like to use `src/` directory? … No
✔ Would you like to use App Router? (recommended) … Yes
✔ Would you like to customize the default import alias (@/*)? … No

❯ cd nextjs-styledcomponent-sb

Yarn v4 を使うため、ディレクトリで使う Node.js を18系にセットします。

zsh
❯ nodenv local 18.18.2
// このコマンドで 4系をリポジトリに導入
❯ yarn set version stable   
➤ YN0000: Downloading https://repo.yarnpkg.com/4.0.2/packages/yarnpkg-cli/bin/yarn.js
➤ YN0000: Saving the new release in .yarn/releases/yarn-4.0.2.cjs
➤ YN0000: Done in 0s 86ms

Next.js のセットアップ

styled-components を使うために、next.config.jsを以下のように書き換えます。

next.config.js
/** @type {import('next').NextConfig} */
// 以下のオプションを設定
const nextConfig = {
  reactStrictMode: true,
  compiler: {
    styledComponents: true,
  },
}

module.exports = nextConfig

styled-components と関連パッケージのインストール

zsh
❯ yarn add styled-components
❯ yarn add -D @types/styled-components babel-plugin-styled-components styled-reset 

ここまで行い、yarn devで Next.js のスタートページが立ち上がることを確認しましょう。

Next.js と styled-components を統合する

registry.tsx

appディレクトリ内にregistry.tsxというファイルを作成し、styled-components を Next.js の各ファイルで使用できるよう設定します。
参考:https://dev.to/rashidshamloo/using-styled-components-with-nextjs-v13-typescript-2l6m#4

registry.tsx
'use client'

import React, { useState } from 'react'
import { useServerInsertedHTML } from 'next/navigation'
import { ServerStyleSheet, StyleSheetManager } from 'styled-components'

export default function StyledComponentsRegistry({
  children,
}: {
  children: React.ReactNode
}) {
  // Only create stylesheet once with lazy initial state
  // x-ref: https://reactjs.org/docs/hooks-reference.html#lazy-initial-state
  const [styledComponentsStyleSheet] = useState(() => new ServerStyleSheet())

  useServerInsertedHTML(() => {
    const styles = styledComponentsStyleSheet.getStyleElement()
    styledComponentsStyleSheet.instance.clearTag()
    return <>{styles}</>
  })

  if (typeof window !== 'undefined') return <>{children}</>

  return (
    <StyleSheetManager sheet={styledComponentsStyleSheet.instance}>
      {children}
    </StyleSheetManager>
  )
}

GlobalStyles.ts

次に、globals.cssの中身をまるっと styled-components に置き換えます。stylesディレクトリを作成し、GlobalStyles.tsという名前でファイルを作成して下記のように css を移植してください。
参考:https://dev.to/rashidshamloo/using-styled-components-with-nextjs-v13-typescript-2l6m#5

GlocalStyles.ts
'use client';
import { createGlobalStyle } from 'styled-components';

const GlobalStyles = createGlobalStyle`
  // your global styles
  :root {
    --max-width: 1100px;
    --border-radius: 12px;
    --font-mono: ui-monospace, Menlo, Monaco, 'Cascadia Mono', 'Segoe UI Mono',
      'Roboto Mono', 'Oxygen Mono', 'Ubuntu Monospace', 'Source Code Pro',
      'Fira Mono', 'Droid Sans Mono', 'Courier New', monospace;

    --foreground-rgb: 0, 0, 0;
    --background-start-rgb: 214, 219, 220;
    --background-end-rgb: 255, 255, 255;

    --primary-glow: conic-gradient(
      from 180deg at 50% 50%,
      #16abff33 0deg,
      #0885ff33 55deg,
      #54d6ff33 120deg,
      #0071ff33 160deg,
      transparent 360deg
    );
    --secondary-glow: radial-gradient(
      rgba(255, 255, 255, 1),
      rgba(255, 255, 255, 0)
    );

    --tile-start-rgb: 239, 245, 249;
    --tile-end-rgb: 228, 232, 233;
    --tile-border: conic-gradient(
      #00000080,
      #00000040,
      #00000030,
      #00000020,
      #00000010,
      #00000010,
      #00000080
    );

    --callout-rgb: 238, 240, 241;
    --callout-border-rgb: 172, 175, 176;
    --card-rgb: 180, 185, 188;
    --card-border-rgb: 131, 134, 135;
  }

  @media (prefers-color-scheme: dark) {
    :root {
      --foreground-rgb: 255, 255, 255;
      --background-start-rgb: 0, 0, 0;
      --background-end-rgb: 0, 0, 0;

      --primary-glow: radial-gradient(rgba(1, 65, 255, 0.4), rgba(1, 65, 255, 0));
      --secondary-glow: linear-gradient(
        to bottom right,
        rgba(1, 65, 255, 0),
        rgba(1, 65, 255, 0),
        rgba(1, 65, 255, 0.3)
      );

      --tile-start-rgb: 2, 13, 46;
      --tile-end-rgb: 2, 5, 19;
      --tile-border: conic-gradient(
        #ffffff80,
        #ffffff40,
        #ffffff30,
        #ffffff20,
        #ffffff10,
        #ffffff10,
        #ffffff80
      );

      --callout-rgb: 20, 20, 20;
      --callout-border-rgb: 108, 108, 108;
      --card-rgb: 100, 100, 100;
      --card-border-rgb: 200, 200, 200;
    }
  }

  * {
    box-sizing: border-box;
    padding: 0;
    margin: 0;
  }

  html,
  body {
    max-width: 100vw;
    overflow-x: hidden;
  }

  body {
    color: rgb(var(--foreground-rgb));
    background: linear-gradient(
        to bottom,
        transparent,
        rgb(var(--background-end-rgb))
      )
      rgb(var(--background-start-rgb));
  }

  a {
    color: inherit;
    text-decoration: none;
  }

  @media (prefers-color-scheme: dark) {
    html {
      color-scheme: dark;
    }
  }
`;

export default GlobalStyles;

layout への反映

上記のファイルをlayout.tsxに import し、appディレクトリ内の.tsxファイルで styled-components が有効になるよう変更を加えます。

layout.tsx
+ 'use client';
- import type { Metadata } from 'next'
- import './globals.css'
+ import GlobalStyles from '../styles/GlobalStyles';
+ import StyledComponentsRegistry from './registry'

- export const metadata: Metadata = {
-   title: 'Create Next App',
-   description: 'Generated by create next app',
- }

export default function RootLayout(
  props: React.PropsWithChildren<{}>
 ) {
  return (
    <html lang="en">
      <body>
      <StyledComponentsRegistry>
        <GlobalStyles />
        {props.children}
      </StyledComponentsRegistry>
      </body>
    </html>
  )
}

ここまで作業が終わったら、yarn devで初期ページが崩れずに表示されていることを確認してください。

セットアップ:storybook

以下のコマンドで storybook をセットアップします。yarn dlxnpxライクなコマンドで、パッケージをダウンロードした後、直接実行できるコマンドです。

zsh
yarn dlx storybook init

このままstorybookが起動すれば成功なのですが、私の環境では以下のようなエラーが吐き出されました。

SB_CORE-SERVER_0002 (CriticalPresetLoadError): Storybook failed to load the following preset: ./.yarn/__virtual__/@storybook-nextjs-virtual-8a558cf2c1/3/.yarn/berry/cache/@storybook-nextjs-npm-7.6.4-421d91b38b-10c0.zip/node_modules/@storybook/nextjs/preset.

Please check whether your setup is correct, the Storybook dependencies (and their peer dependencies) are installed correctly and there are no package version clashes.

If you believe this is a bug, please open an issue on Github.

Error: @storybook/nextjs tried to access webpack (a peer dependency) but it isn't provided by your application; this makes the require call ambiguous and unsound.

Required package: webpack
Required by: @storybook/nextjs@virtual:2e7e27f6cca61c8aa5b8f67bb080d6a92331f92327d9126df87bfe1646b6ea8d8f982aeb8c315f2256aa814646aa2b709c5813c6ddf465d80be2f357cc1b1b56#npm:7.6.4 (via ./.yarn/__virtual__/@storybook-nextjs-virtual-8a558cf2c1/3/.yarn/berry/cache/@storybook-nextjs-npm-7.6.4-421d91b38b-10c0.zip/node_modules/@storybook/nextjs/dist/)
Ancestor breaking the chain: nextjs-styledcomponent-sb@workspace:.

もし、同様なビルドエラーが発生したらこの時点でコミットしておくことをおすすめします。エラーの原因を解消するために各種設定ファイルなどをこねくりまわして泥沼にハマってしまうことがあるので・・・。

ビルドエラーを解消するための追加セットアップ

調査したところ、下記の記事で同じようなパッケージ構成でstorybookが動かない時の対処法を解説されていました。
https://qiita.com/cabbage_lettuce/items/56b181e8be213ffbdea1

再度 yarn set version stableでYarn v4を設定した後、上記記事の通りに.yarnrc.ymlに変更を加えます。

.yarnrcyml
packageExtensions:
  "@storybook/nextjs@*":
    dependencies:
      "@babel/core": "*"
      webpack: "*"

追加で下記のパッケージをインストールします。

zsh
corepack yarn add -D styled-jsx postcss-import postcss-loader css-loader postcss-url

上記の対応を行ったところ、無事に storybook のビルドが成功しました🎉

zsh
Running Storybook
@storybook/cli v7.6.4

info => Starting manager..
info => Starting preview..
info Using Babel compiler
info Addon-docs: using MDX2
info => Using implicit CSS loaders
info => Using default Webpack5 setup
<i> [webpack-dev-middleware] wait until bundle finished
<i> [webpack-dev-middleware] wait until bundle finished: /runtime~main.iframe.bundle.js
<i> [webpack-dev-middleware] wait until bundle finished: /vendors-_yarn_berry_cache_storybook-addon-interactions-npm-7_6_4-82ebd13def-10c0_zip_node_mod-84efbf.iframe.bundle.js

storybook の設定変更

app/components配下にコンポーネントと一緒に stories ファイルを管理したいと考えたため、.storybook/main.tsの中身を以下のように書き換えました。Next.js のセットアップ時にTypeScriptを指定しているため、 storybook の設定ファイルも.tsで作成されています。

main.ts
const config: StorybookConfig = {
  stories: [
    "../stories/**/*.mdx",
-     "../stories/**/*.stories.@(js|jsx|mjs|ts|tsx)",
+     "../app/**/*.stories.@(js|jsx|mjs|ts|tsx)",
  ],

コンポーネントと stories ファイルの追加

storybook が機能することを確認するため、app/components配下にシンプルなボタンを作成し、それの対となる stories ファイルを作成し、読み込ませます。

SimpleButton.tsx

SimpleButton.tsx
'use client';
import styled from 'styled-components';

const StyledButton = styled.button<{ color: string }>`
  background-color: ${({ color }) => color };
  color: white;
  border-radius: 4px;
  padding: 8px;
  align-items: center;
  &:hover {
    cursor: pointer;
  }
`;

type ButtonProps = {
  label: string;
  color?: string;
  onClick?: () => void;
};

export const SimpleButton: ButtonProps = ({ label, color = '#3700ff', onClick = () => {} }) => {
  return (
    <StyledButton color={color} onClick={onClick}>
      {label}
    </StyledButton>
  );
};

stories

storybook v7ではComponentMeta,ComponentStoryObjが非推奨になり、Meta,StoryObjという型を使うことが推奨されるようになりました。
参考:https://zenn.dev/route06/articles/storybook-v7-deprecations

SimpleButton.stories.tsx
import React from 'react';
import { StoryObj, Meta } from '@storybook/react';

import { SimpleButton } from './SimpleButton';

const meta: Meta<typeof SimpleButton> = {
  component: SimpleButton,
  args: {
    label: 'Simple Button',
    color: '#3700ff',
    onClick: () => {}
  }
};

export default meta;

type Story = StoryObj<typeof SimpleButton>;

export const Default: Story = {
  argTypes: {
    onClick: { action: true },
  },
}

storybookで確認

最後に、yarn storybookで再度storybookを開いてコンポーネントのstoriesが作成されているか確認します。

無事にコンポーネントがstorybookで管理できるようになりました🎉
このボイラープレートを使いながら今後の個人開発をスムーズに行っていきたいと思います。

Discussion