Next.js + CSS Variables でテーマカラーを実現する


こんにちは、 Gaji-Labo フロントエンドエンジニアの石垣です。

今回は Gaji-Labo が開発に関わっているプロダクトで、Next.js において CSS Variables を使ってプロダクトの運用途中からのテーマカラー実装を行ったので、その方法を紹介します。

実装経緯

今回の記事で取り扱うプロダクトではマルチテナントをサポートしており、テナントごとに独自のサブドメインを持つアプリケーションを運用しています。

開発当初は各テナントごとのテーマカラーの対応は要件に含まれていなかったため、ヘッダーやフッターなどのUIコンポーネントの色は固定値を変数化して管理していました。

しかしプロダクトを利用するユーザーが増えるにつれ、テナントごとにテーマカラーを変更したいという要望が出てきました。

当プロダクトではUIフレームワークにMUIを使っており、MUIにはテーマカラーを実装するための機能が用意されているため、理想としてはその機能を使いたいところでしたが、既存のコードベースが大きくなっている状況での導入は大規模な改修が必要になることが予想されました。

アプローチの検討

そこで、既存のコードベースに大きな変更を加えずにテーマカラーを実装する方法として CSS Variables を使うことを検討しました。

CSS Variables を使うことで動的な値の変更が用意であり、既存のスタイリングコードへの影響も最小限に抑えられます。

具体的にはテナントごとのテーマカラーを提供するAPIエンドポイントを作成し、そこから取得した値を CSS Variables に適用します。

さらにAPIからのデータ取得に失敗した場合や、値が不正な場合に備えてフォールバック用のデフォルトテーマカラーも用意しておきます。

具体的な実装

テーマカラーAPIの実装

まずは、テナントごとのテーマカラーを提供するAPIエンドポイントを作成します。

ここでは例として Next.js の API Routes を使ってシンプルなエンドポイントを実装してみました。

import type { NextApiRequest, NextApiResponse } from 'next';

type ResponseData = {
  themeColorJson: Record<string, string>;
};

export default function handler(
  req: NextApiRequest,
  res: NextApiResponse<ResponseData>
) {
  res.status(200).json({
    themeColorJson: {
      header_background_color: '#212121',
      header_text_color: '#eeeeee',
      button_background_color: '#ee2121',
      button_text_color: '#2121ee',
    },
  });
}

このAPIは、各UIコンポーネントに必要な色情報を JSON 形式で返すエンドポイントです。

実際の運用ではテナントのIDに応じて適切な色情報を返却するように実装しますが、今回はシンプルな例として固定値を返却しています。

CSS Variables の動的生成

次に、APIから取得したテーマカラーを CSS Variables として適用するためのコードを実装します。

ここが今回の実装の核になります。

import ck from 'camelcase-keys';
import { GetServerSideProps } from 'next';

const TENANT_THEME_COLOR_DEFAULT = {
  headerBackgroundColor: '#212121',
  headerTextColor: '#eeeeee',
  buttonBackgroundColor: '#42aa42',
  buttonTextColor: '#4242aa',
};

export type ThemeColorType = typeof TENANT_THEME_COLOR_DEFAULT;
export const themeColorKeys = Object.keys(TENANT_THEME_COLOR_DEFAULT);

const getThemeColorJson = (
  themeColorJson: ThemeColorType
): ThemeColorType | undefined => {
  const parsedThemeColorJson = ck(themeColorJson);
  return parsedThemeColorJson;
};

export const getServerSideProps: GetServerSideProps = async ({ req, res }) => {
  const response = await fetch('http://localhost:3000/api/theme');
  const { themeColorJson } = await response.json();

  const parsedThemeColorJson: ThemeColorType | undefined = themeColorJson
    ? getThemeColorJson(themeColorJson as ThemeColorType)
    : undefined;

  const filteredThemeColorJson = parsedThemeColorJson
    ? Object.keys(parsedThemeColorJson).reduce((acc, key) => {
        if (themeColorKeys.includes(key)) {
          return {
            ...acc,
            [key]: parsedThemeColorJson[key as keyof ThemeColorType],
          };
        }
        return acc;
      }, {} as ThemeColorType)
    : {};

  const styles = Object.entries({
    ...TENANT_THEME_COLOR_DEFAULT,
    ...filteredThemeColorJson,
  })
    .map(([key, value]) => {
      return `--color-${key}: ${value};`;
    })
    .join('');

  res.setHeader('Content-Type', 'text/css');
  res.write(`:root { ${styles} }`);
  res.end();

  return { props: {} };
};

const ThemeCss = (): void => {
  // empty
};

export default ThemeCss;

まずは、TENANT_THEME_COLOR_DEFAULT にフォールバック用のデフォルトテーマカラーを定義します。ここから型定義も行います。

const TENANT_THEME_COLOR_DEFAULT = {
  headerBackgroundColor: '#212121',
  headerTextColor: '#eeeeee',
  buttonBackgroundColor: '#42aa42',
  buttonTextColor: '#4242aa',
};

export type ThemeColorType = typeof TENANT_THEME_COLOR_DEFAULT;
export const themeColorKeys = Object.keys(TENANT_THEME_COLOR_DEFAULT);

次に、APIから取得したテーマカラーをパースする関数 getThemeColorJson を定義します。

camelcase-keys パッケージを使ってキーをキャメルケースに変換しています。

const getThemeColorJson = (
  themeColorJson: ThemeColorType
): ThemeColorType | undefined => {
  const parsedThemeColorJson = ck(themeColorJson);
  return parsedThemeColorJson;
};

続いて getServerSideProps 内でAPIからテーマカラーを取得し、フォールバック用のデフォルトテーマカラーとマージします。

filteredThemeColorJson は不正な値を除外するために、themeColorKeys に含まれるキーのみを抽出しています。

const response = await fetch('http://localhost:3000/api/theme');
const { themeColorJson } = await response.json();

const parsedThemeColorJson: ThemeColorType | undefined = themeColorJson
  ? getThemeColorJson(themeColorJson as ThemeColorType)
  : undefined;

const filteredThemeColorJson = parsedThemeColorJson
    ? Object.keys(parsedThemeColorJson).reduce((acc, key) => {
        if (themeColorKeys.includes(key)) {
            return {
            ...acc,
            [key]: parsedThemeColorJson[key as keyof ThemeColorType],
            };
        }
        return acc;
        }, {} as ThemeColorType)
    : {};

最後に、CSS Variables を動的に生成してレスポンスとして返却します。

const styles = Object.entries({
  ...TENANT_THEME_COLOR_DEFAULT,
  ...filteredThemeColorJson,
})
  .map(([key, value]) => {
    return `--color-${key}: ${value};`;
  })
  .join('');

res.setHeader('Content-Type', 'text/css');
res.write(`:root { ${styles} }`);
res.end();

これで API から取得したテーマカラーが CSS Variables として適用されるようになりました。

テーマカラーの適用

実際にテーマカラーを適用するコンポーネントを作成します。

import React from 'react';
import AppBar from '@mui/material/AppBar';
import Box from '@mui/material/Box';
import Toolbar from '@mui/material/Toolbar';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import IconButton from '@mui/material/IconButton';
import MenuIcon from '@mui/icons-material/Menu';
import { styled } from '@mui/material/styles';
import { themeColorKeys, ThemeColorType } from './theme.css';

const themeColors = themeColorKeys.reduce((acc, key) => {
  return {
    ...acc,
    [key]: `var(--color-${key})`,
  };
}, {} as ThemeColorType);

export const colors = {
  ...themeColors,
};

const CustomizedAppBar = styled(AppBar)`
  background-color: ${colors.headerBackgroundColor};
  color: ${colors.headerTextColor};
`;

const CustomizedButton = styled(Button)`
  background-color: ${colors.buttonBackgroundColor};
  color: ${colors.buttonTextColor};
`;

function Home() {
  return (
    <Box sx={{ flexGrow: 1 }}>
      <CustomizedAppBar position="static">
        <Toolbar>
          <IconButton
            size="large"
            edge="start"
            color="inherit"
            aria-label="menu"
            sx={{ mr: 2 }}
          >
            <MenuIcon />
          </IconButton>
          <Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
            News
          </Typography>
          <Button color="inherit">Login</Button>
        </Toolbar>
      </CustomizedAppBar>
      <CustomizedButton>Button</CustomizedButton>
    </Box>
  );
}

export default Home;

ここでは例として MUI の AppBar と Button にテーマカラーを適用しています。

colors 変数にテーマカラーを定義し、styled 関数を使って AppBar コンポーネントに適用しています。

これにより、APIから取得したテーマカラーが適用されるようになります。

APIで指定したテーマカラーがヘッダーとボタンに適用されている様子のキャプチャ
APIで指定したテーマカラーがヘッダーとボタンに適用されている

ここで例えば実装が想定していない値、header_icon_color などが入ってきた場合は theme.css には反映されず無視されます。

まとめ

今回の記事では、Next.js で CSS Variables を使ってテーマカラーを実装する方法を紹介しました。

CSS Variables を活用することで、既存のコードへの影響を最小限に抑えつつ、動的なテーマカラーの適用を実現することができます。特にマルチテナントをサポートするアプリケーションにおいて、コストを抑えてテナントごとのテーマカラーを適用する際に有用な手法です。

既存のアプリケーションに後からテーマカラー機能を追加する場合、理想としては MUI のテーマカラー機能を使いたいところですが、現実的なプロダクト開発では難しい場合も多々あります。本記事で紹介した方法は、そのような状況下での解決策のひとつになります。

個人的な学びとしても、今回の実装を通じて具体的な実装方法のみならず、既存のアプリケーションを拡張する際に理想的な実装と現実的な制約のバランスを取ることの重要性を再認識しました。

Gaji-Labo では、顕在・潜在問わずプロダクトを成長させていくなかで日々生まれる課題に対して、多方面からのアプローチを通じて最適な解決策を考え提供しています。

そうした課題解決のアプローチに興味を持たれた方、または技術的な相談をお考えの方は、ぜひお気軽にお問い合わせください。

Gaji-Labo フロントエンドエンジニア向けご案内資料

Gaji-Labo は新規事業やサービス開発に取り組む、事業会社・スタートアップへの支援を行っています。

弊社では、Next.js を用いた Web アプリケーションのフロントエンド開発をリードするフロントエンドエンジニアを募集しています!さまざまなプロダクトやチームに関わりながら、一緒に成長を体験しませんか?

もちろん、一緒にお仕事をしてくださるパートナーさんも随時募集中です。まずはお気軽に声をかけてください!

求人応募してみる!


投稿者 Ishigaki Shotaro

未経験から Gaji-Labo に入社。現在は React/TypeScript/Next.js の案件で MUI を使ったコンポーネント組み込みを担当。プロジェクトチームのリードとして共に組み込み作業をしているメンバーの進行管理も行っています。休日はだいたい家で音楽を聴いており、たまにライブに出かけています。