T-CREATOR

Emotion × Next.js:サーバーサイドレンダリング対応のベストプラクティス

Emotion × Next.js:サーバーサイドレンダリング対応のベストプラクティス

モダンな Web アプリケーション開発において、パフォーマンスとユーザー体験の向上は避けて通れない課題です。特に、CSS-in-JS ライブラリである Emotion と Next.js を組み合わせる際、サーバーサイドレンダリング(SSR)対応は多くの開発者が直面する壁となっています。

この記事では、Emotion と Next.js を組み合わせた際の SSR 対応について、実際のエラーとその解決策を含めて詳しく解説します。初心者の方でも理解しやすいよう、段階的に説明していきますので、ぜひ最後までお付き合いください。

Emotion と Next.js の基本理解

Emotion とは

Emotion は、JavaScript で CSS を記述できる CSS-in-JS ライブラリです。React との相性が良く、動的なスタイリングやテーマ機能を簡単に実装できることが特徴です。

javascript// Emotionの基本的な使用例
import styled from '@emotion/styled';

const Button = styled.button`
  background-color: #007bff;
  color: white;
  padding: 10px 20px;
  border: none;
  border-radius: 4px;
  cursor: pointer;

  &:hover {
    background-color: #0056b3;
  }
`;

Emotion の魅力は、JavaScript の変数や関数を直接 CSS に組み込めることです。これにより、動的なスタイリングが非常に簡単になります。

Next.js の SSR とは

Next.js のサーバーサイドレンダリング(SSR)は、ページの HTML をサーバー側で事前に生成し、クライアントに送信する技術です。これにより、初期表示速度の向上と SEO 対策が実現できます。

javascript// Next.jsのSSRページ例
export default function HomePage({ data }) {
  return (
    <div>
      <h1>サーバーサイドで生成されたコンテンツ</h1>
      <p>{data.message}</p>
    </div>
  );
}

// サーバーサイドでデータを取得
export async function getServerSideProps() {
  const data = await fetchData();
  return {
    props: { data },
  };
}

SSR の最大のメリットは、検索エンジンがページの内容を正確に理解できることです。これにより、SEO スコアの向上が期待できます。

組み合わせるメリット

Emotion と Next.js を組み合わせることで、以下のようなメリットが得られます:

  • 動的スタイリング: JavaScript の変数や状態に基づいたスタイル変更
  • コンポーネント化: スタイルとロジックを一つのファイルにまとめられる
  • テーマ機能: アプリケーション全体で一貫したデザインシステムの構築
  • 開発効率: CSS ファイルの管理が不要になり、開発速度が向上

しかし、この組み合わせには SSR 対応という課題が待ち受けています。

SSR 対応における Emotion の課題

サーバーサイドでのスタイル生成

Emotion を Next.js で使用する際、最も大きな課題はサーバーサイドでのスタイル生成です。通常の CSS-in-JS はクライアントサイドで動作するため、サーバーサイドではスタイルが適用されません。

javascript// 問題のあるコード例
import styled from '@emotion/styled';

const StyledComponent = styled.div`
  background-color: blue;
  color: white;
`;

export default function Page() {
  return <StyledComponent>Hello World</StyledComponent>;
}

このコードを SSR で実行すると、以下のような問題が発生します:

  • サーバーサイドではスタイルが適用されない
  • クライアントサイドでハイドレーション時にスタイルが適用される
  • 一瞬スタイルが適用されていない状態が見える(FOUC: Flash of Unstyled Content)

ハイドレーション問題

ハイドレーションとは、サーバーサイドで生成された HTML にクライアントサイドの JavaScript を適用するプロセスです。Emotion の場合、この過程でスタイルの不一致が発生することがあります。

javascript// ハイドレーションエラーの例
// サーバーサイドとクライアントサイドで異なるスタイルが生成される
const DynamicComponent = styled.div`
  background-color: ${(props) => props.theme.primary};
  color: ${(props) => props.theme.text};
`;

このような動的スタイルを使用すると、サーバーサイドとクライアントサイドで異なるスタイルが生成され、ハイドレーションエラーが発生する可能性があります。

パフォーマンスへの影響

SSR 対応を適切に行わない場合、以下のようなパフォーマンス問題が発生します:

  • バンドルサイズの増大: 不要なスタイルコードが含まれる
  • レンダリング速度の低下: スタイルの再計算が頻繁に発生
  • メモリ使用量の増加: スタイルキャッシュが適切に管理されない

解決策:@emotion/react と@emotion/server

必要なパッケージの導入

Emotion の SSR 対応には、以下のパッケージが必要です:

bashyarn add @emotion/react @emotion/styled @emotion/server

これらのパッケージの役割は以下の通りです:

  • @emotion​/​react: React 用の Emotion ライブラリ
  • @emotion​/​styled: styled-components 風の API
  • @emotion​/​server: サーバーサイドでのスタイル抽出

サーバーサイド設定

Next.js の_document.tsxファイルでサーバーサイドの設定を行います:

javascript// pages/_document.tsx
import Document, {
  Html,
  Head,
  Main,
  NextScript,
} from 'next/document';
import { extractCritical } from '@emotion/server';

export default class MyDocument extends Document {
  static async getInitialProps(ctx) {
    const initialProps = await Document.getInitialProps(
      ctx
    );
    const critical = extractCritical(initialProps.html);

    return {
      ...initialProps,
      styles: (
        <>
          {initialProps.styles}
          <style
            data-emotion-css={critical.ids.join(' ')}
            dangerouslySetInnerHTML={{
              __html: critical.css,
            }}
          />
        </>
      ),
    };
  }

  render() {
    return (
      <Html>
        <Head />
        <body>
          <Main />
          <NextScript />
        </body>
      </Html>
    );
  }
}

この設定により、サーバーサイドで生成されたスタイルが HTML に埋め込まれ、クライアントサイドでのハイドレーションがスムーズに行われます。

クライアントサイド設定

_app.tsxファイルでクライアントサイドの設定を行います:

javascript// pages/_app.tsx
import { CacheProvider } from '@emotion/react';
import createCache from '@emotion/cache';

const clientSideEmotionCache = createCache({ key: 'css' });

export default function MyApp({
  Component,
  pageProps,
  emotionCache = clientSideEmotionCache,
}) {
  return (
    <CacheProvider value={emotionCache}>
      <Component {...pageProps} />
    </CacheProvider>
  );
}

この設定により、クライアントサイドでのスタイル管理が適切に行われ、ハイドレーションエラーを防ぐことができます。

実装例:基本的な SSR 対応

_document.tsx の設定

より詳細な_document.tsxの設定例をご紹介します:

javascript// pages/_document.tsx
import Document, {
  Html,
  Head,
  Main,
  NextScript,
} from 'next/document';
import { extractCriticalToChunks } from '@emotion/server';
import createEmotionServer from '@emotion/server/create-instance';
import createCache from '@emotion/cache';

const cache = createCache({ key: 'css' });
const { extractCritical } = createEmotionServer(cache);

export default class MyDocument extends Document {
  static async getInitialProps(ctx) {
    const originalRenderPage = ctx.renderPage;

    ctx.renderPage = () =>
      originalRenderPage({
        enhanceApp: (App) => (props) =>
          <App emotionCache={cache} {...props} />,
      });

    const initialProps = await Document.getInitialProps(
      ctx
    );
    const emotionStyles = extractCritical(
      initialProps.html
    );

    return {
      ...initialProps,
      styles: (
        <>
          {initialProps.styles}
          <style
            data-emotion={`css ${emotionStyles.ids.join(
              ' '
            )}`}
            dangerouslySetInnerHTML={{
              __html: emotionStyles.css,
            }}
          />
        </>
      ),
    };
  }

  render() {
    return (
      <Html lang='ja'>
        <Head>
          <meta charSet='utf-8' />
          <link rel='icon' href='/favicon.ico' />
        </Head>
        <body>
          <Main />
          <NextScript />
        </body>
      </Html>
    );
  }
}

この設定では、より詳細なキャッシュ管理とスタイル抽出を行っています。

_app.tsx の設定

_app.tsxでのより高度な設定例です:

javascript// pages/_app.tsx
import { CacheProvider } from '@emotion/react';
import { ThemeProvider } from '@emotion/react';
import createCache from '@emotion/cache';

// クライアントサイドのキャッシュを作成
const clientSideEmotionCache = createCache({
  key: 'css',
  prepend: true, // CSSの優先順位を上げる
});

// テーマの定義
const theme = {
  colors: {
    primary: '#007bff',
    secondary: '#6c757d',
    success: '#28a745',
    danger: '#dc3545',
  },
  spacing: {
    small: '8px',
    medium: '16px',
    large: '24px',
  },
};

export default function MyApp({
  Component,
  pageProps,
  emotionCache = clientSideEmotionCache,
}) {
  return (
    <CacheProvider value={emotionCache}>
      <ThemeProvider theme={theme}>
        <Component {...pageProps} />
      </ThemeProvider>
    </CacheProvider>
  );
}

この設定により、テーマ機能も活用できるようになります。

コンポーネントでの使用例

実際のコンポーネントでの使用例をご紹介します:

javascript// components/Button.jsx
import styled from '@emotion/styled';

const StyledButton = styled.button`
  background-color: ${(props) =>
    props.variant === 'primary'
      ? props.theme.colors.primary
      : props.theme.colors.secondary};
  color: white;
  padding: ${(props) => props.theme.spacing.medium};
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 16px;
  transition: background-color 0.2s ease;

  &:hover {
    background-color: ${(props) =>
      props.variant === 'primary' ? '#0056b3' : '#545b62'};
  }

  &:disabled {
    opacity: 0.6;
    cursor: not-allowed;
  }
`;

export default function Button({
  children,
  variant = 'primary',
  ...props
}) {
  return (
    <StyledButton variant={variant} {...props}>
      {children}
    </StyledButton>
  );
}

このコンポーネントは、テーマを使用して動的なスタイリングを行い、SSR でも適切に動作します。

高度な設定と最適化

キャッシュ戦略

パフォーマンスを向上させるためのキャッシュ戦略を実装します:

javascript// utils/emotionCache.js
import createCache from '@emotion/cache';

// 本番環境でのキャッシュ設定
const isBrowser = typeof document !== 'undefined';

export default function createEmotionCache() {
  let insertionPoint;

  if (isBrowser) {
    const emotionInsertionPoint = document.querySelector(
      'meta[name="emotion-insertion-point"]'
    );
    insertionPoint = emotionInsertionPoint ?? undefined;
  }

  return createCache({
    key: 'mui-style',
    insertionPoint,
    prepend: true,
  });
}

このキャッシュ設定により、スタイルの挿入順序を制御し、CSS の優先順位を適切に管理できます。

動的スタイルの処理

動的スタイルを安全に処理する方法をご紹介します:

javascript// components/DynamicCard.jsx
import styled from '@emotion/styled';

const Card = styled.div`
  background-color: ${(props) => {
    // サーバーサイドとクライアントサイドで一貫性を保つ
    if (typeof window === 'undefined') {
      return '#ffffff'; // サーバーサイドでのデフォルト値
    }
    return props.isDark ? '#2c3e50' : '#ffffff';
  }};
  color: ${(props) =>
    props.isDark ? '#ffffff' : '#2c3e50'};
  padding: 20px;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  transition: all 0.3s ease;
`;

export default function DynamicCard({ isDark, children }) {
  return <Card isDark={isDark}>{children}</Card>;
}

この実装では、サーバーサイドとクライアントサイドで一貫したスタイルを保つため、条件分岐を使用しています。

パフォーマンス最適化

バンドルサイズとレンダリング速度を最適化する設定です:

javascript// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')(
  {
    enabled: process.env.ANALYZE === 'true',
  }
);

module.exports = withBundleAnalyzer({
  // Emotionの最適化設定
  experimental: {
    optimizeCss: true,
  },

  // 不要なCSSの削除
  webpack: (config, { dev, isServer }) => {
    if (!dev && !isServer) {
      config.optimization.splitChunks.cacheGroups.styles = {
        name: 'styles',
        test: /\.(css|scss)$/,
        chunks: 'all',
        enforce: true,
      };
    }
    return config;
  },
});

この設定により、本番環境での CSS バンドルが最適化され、パフォーマンスが向上します。

よくある問題とトラブルシューティング

スタイルの不一致

最もよく発生する問題は、サーバーサイドとクライアントサイドでのスタイルの不一致です。

エラー例:

arduinoWarning: Text content did not match. Server: "Hello" Client: "Hello"

解決策:

javascript// コンポーネントでの解決例
import { useEffect, useState } from 'react';
import styled from '@emotion/styled';

const DynamicComponent = styled.div`
  background-color: ${(props) => props.bgColor};
`;

export default function MyComponent() {
  const [mounted, setMounted] = useState(false);
  const [bgColor, setBgColor] = useState('#ffffff'); // デフォルト値

  useEffect(() => {
    setMounted(true);
    // クライアントサイドでのみ実行
    const userPreference = localStorage.getItem('theme');
    if (userPreference === 'dark') {
      setBgColor('#2c3e50');
    }
  }, []);

  // サーバーサイドではデフォルト値を表示
  if (!mounted) {
    return (
      <DynamicComponent bgColor='#ffffff'>
        Loading...
      </DynamicComponent>
    );
  }

  return (
    <DynamicComponent bgColor={bgColor}>
      Content
    </DynamicComponent>
  );
}

メモリリーク対策

長時間動作するアプリケーションでのメモリリークを防ぐ設定です:

javascript// utils/emotionCache.js
import createCache from '@emotion/cache';

let emotionCache;

if (typeof window !== 'undefined') {
  // クライアントサイドでのみキャッシュを作成
  emotionCache = createCache({
    key: 'css',
    prepend: true,
    // メモリリークを防ぐための設定
    stylisPlugins: [],
  });
}

export default emotionCache;

デバッグ手法

Emotion のデバッグを効率的に行うための設定です:

javascript// pages/_app.tsx
import { CacheProvider } from '@emotion/react';
import createCache from '@emotion/cache';

const clientSideEmotionCache = createCache({
  key: 'css',
  // デバッグモードの有効化
  ...(process.env.NODE_ENV === 'development' && {
    stylisPlugins: [
      // 開発環境でのスタイルデバッグ
      (context, content, selectors) => {
        if (context === 1) {
          console.log('Generated CSS:', content);
        }
      },
    ],
  }),
});

export default function MyApp({
  Component,
  pageProps,
  emotionCache = clientSideEmotionCache,
}) {
  return (
    <CacheProvider value={emotionCache}>
      <Component {...pageProps} />
    </CacheProvider>
  );
}

まとめ

Emotion と Next.js の SSR 対応は、最初は複雑に感じるかもしれませんが、適切な設定とベストプラクティスを理解することで、パフォーマンスの高いアプリケーションを構築できます。

今回ご紹介した内容を実践することで、以下のような成果が期待できます:

  • SEO 対策: サーバーサイドでのスタイル適用により、検索エンジンが正確にページを認識
  • パフォーマンス向上: 適切なキャッシュ戦略により、レンダリング速度が向上
  • 開発効率: コンポーネントベースのスタイリングにより、保守性が向上
  • ユーザー体験: FOUC の解消により、スムーズなページ表示を実現

実際のプロジェクトでこれらの設定を適用する際は、段階的に導入することをお勧めします。まずは基本的な SSR 対応から始め、徐々に高度な最適化を適用していくことで、安全に移行できます。

Emotion と Next.js の組み合わせは、モダンな Web アプリケーション開発において非常に強力なツールです。この記事で学んだ知識を活用して、素晴らしいアプリケーションを作成してください。

関連リンク