ブログ

👭 ライト/ダークモードをハックして、1サイト分のコストで2つのNext.jsサイトを構築する

Leonardo Losoviz
著者: Leonardo Losoviz ·

最近、Gato GraphQL チームは Gato Plugins をリリースしました。Gato GraphQL の兄弟サイトです。

両サイトが全く同じサイトであることにお気づきでしょう!唯一の違いは配色スキームです。Gato GraphQL はダークテーマ、Gato Plugins はライトテーマを採用しています。

両サイトのブログセクションはまったく同じです:

gatographql.com のブログセクション
gatographql.com のブログセクション
gatoplugins.com のブログセクション
gatoplugins.com のブログセクション

ドキュメントセクションも同じです:

gatographql.com のドキュメントセクション
gatographql.com のドキュメントセクション
gatoplugins.com のドキュメントセクション
gatoplugins.com のドキュメントセクション

セクションの内容が異なる場合もありますが、根底にある基盤は同じです。

たとえば、Gato GraphQL の拡張機能と Gato Plugins のプラグインは同じレイアウトを使用しています:

gatographql.com の拡張機能セクション
gatographql.com の拡張機能セクション
gatoplugins.com のプラグインセクション
gatoplugins.com のプラグインセクション

(ちなみに、ロゴもほぼ同じです!😜)

gatographql.com のロゴ
gatographql.com のロゴ
gatoplugins.com のロゴ
gatoplugins.com のロゴ

そして、このブログ記事も両サイトに掲載されています!😂

gatographql.com でも読めます:Building 2 Nextjs websites at the price of 1, by hacking the dark/light mode

ただし、2つのサイトの記事にはちょうど7つの違いがあります。すべて見つけられますか?見つけられた方には Gato GraphQL の割引クーポンをプレゼント 🙏

なぜライト/ダークモードを使って2つのウェブサイトを作ったのか

理由はいくつかあります:

2つの別々のコードベースを維持する時間も体力もありません。シンプルに保つ必要があります

ウェブサイトに費やす時間は、プロダクト開発に使えない時間です。

ユーザーが同じファミリーの一部として認識できるよう、見た目を似せたいと思っています。

私はデザイナーではありません。あのルックアンドフィールとスタイルを実現できたことに満足しており、ゼロから作り直したくありませんでした

言い換えると:安くて簡単だからです。膨大な時間と労力を節約でき、それを自分のプロダクトに充てることができました。

デメリットとして、2つのサイトはダーク/ライトモードの切り替えをサポートできないため、スタイルが固定されてしまいます。しかし、それは受け入れられる範囲です。


さて、実際にどのように実装したのか見ていきましょう。

スタック: アプリケーションは Next.js をベースとし、スタイリングには Tailwind CSS を使用しています。

Cruip の複数のテンプレートを組み合わせ、ニーズに合わせてカスタマイズして作成しました。(あのテンプレートは美しい!)

コンテンツは Contentlayer で管理しています。

共通コードを共有パッケージに切り出し、モノレポでホスティングする

両サイトのコードベースは同じなので、モノレポとしてまとめてホスティングするのは理にかなっています。

もともとのリポジトリには1つのプロジェクトしかありませんでした:

  • gatographql.com

それを以下のように再構成しました:

  • apps/gatographql.com: Gato GraphQL ウェブサイト
  • apps/gatoplugins.com: Gato Plugins ウェブサイト
  • packages/shared/gatoapp: 両サイト共通のコード

VSCode でのワークスペースはこのようになっています:

モノレポの構造
モノレポの構造

モノレポに特別なツールは使っていません。シンプルな workspaces で十分です。

モノレポのルートにある package.json は以下のようになっています:

{
  "name": "gatowebsites",
  "version": "2.0.0",
  "private": true,
  "workspaces": [
    "apps/*",
    "packages/shared/*"
  ]
}

さらに、両プロジェクトの実行・ビルド・デプロイ用のスクリプトを package.json に追加しました(両サイトのホスティング先である Netlify へのデプロイスクリプトを含む):

{
  "scripts": {
    "dev-gatographql": "npm run dev --workspace=apps/gatographql",
    "build-gatographql": "npm run build --workspace=apps/gatographql",
    "deploy-gatographql": "npm run deploy-staging-gatographql",
    "deploy-dev-gatographql": "netlify dev --filter gatographql",
    "deploy-staging-gatographql": "netlify deploy --build --context deploy-preview --filter gatographql",
    "deploy-prod-gatographql": "netlify deploy --build --prod --context production --filter gatographql",
 
    "dev-gatoplugins": "npm run dev --workspace=apps/gatoplugins",
    "build-gatoplugins": "npm run build --workspace=apps/gatoplugins",
    "deploy-gatoplugins": "npm run deploy-staging-gatoplugins",
    "deploy-dev-gatoplugins": "netlify dev --filter gatoplugins",
    "deploy-staging-gatoplugins": "netlify deploy --build --context deploy-preview --filter gatoplugins",
    "deploy-prod-gatoplugins": "netlify deploy --build --prod --context production --filter gatoplugins"
  }
}

コンポーネントをカスタムデータをpropsで受け取る形に変換する

できる限り、各サイトのコードを共有パッケージに移動し、propsを通じて動作をカスタマイズします。

たとえば、共有パッケージ gatoapp には BlogSection コンポーネントが含まれています(両サイトの /blog ページを表示するためのものです):

import PopularPosts from 'gatoapp/components/blog/popular-posts'
import PageHeader from 'gatoapp/components/page-header'
import { BlogPostProps } from 'gatoapp/types/list-types'
import BlogSectionPostList from './blog-section-post-list'
import { useEffect, useState, Suspense } from "react";
 
export default function BlogSection({
  blogPosts,
  title = "Our Blog",
  description,
  campaignBanner,
}: {
  blogPosts: BlogPostProps[],
  title?: string,
  description: string,
  campaignBanner?: React.ReactNode
}) {
  const sidebar = (
    <aside className="hidden sm:block relative mt-12 md:mt-0 md:w-64 md:ml-12 lg:ml-20 md:shrink-0">
      <PopularPosts
        blogPosts={blogPosts}
      />
    </aside>
  )
 
  return (
    <div className="max-w-6xl mx-auto px-4 sm:px-6">
      <div className="pt-32 pb-12 md:pt-40 md:pb-20">
 
        {campaignBanner}
 
        {/* Page header */}
        <PageHeader
          title={title}
          description={description}
        />
 
        {/* Main content */}
        <BlogSectionPostList
          blogPosts={blogPosts}
          sidebar={sidebar}
        />
 
      </div>
    </div>
  )
}

コンテンツはすべて同じですが、以下の点が異なります:

  • ページヘッダー(タイトル/説明)
  • ブログ記事
  • キャンペーンバナー

2つのサイトが独立してキャンペーンを実施できるよう、campaignBannerReact.ReactNode として渡すことで、キャンペーンのカスタマイズが制約されません。

たとえば、この記事を公開している時点では Gato GraphQL でキャンペーンを実施していますが、Gato Plugins では実施していません:

gatographql.com のキャンペーンバナー
gatographql.com のキャンペーンバナー

ブログ記事を注入するには、もう少し複雑なロジックが必要です。

ブログ記事の注入

ブログ記事のデータは blogPosts プロップを通じて BlogSection に注入されます。

Contentlayer を使用しているため、各サイトはルートに contentlayer.config.js ファイルを持ち、サイトの型を定義します。

この設定ファイルは共有パッケージ gatoapp に移動できません。そこで、共有型の設定を提供するエクスポートモジュールを作成し、各サイトの contentlayer.config.js でインポートすることで、ロジックをDRYに保ちます。

gatoapp には共有型 BlogPost を提供するエクスポートモジュール contentlayer.config.js があります:

import { defineDocumentType } from 'contentlayer2/source-files'
 
const BlogPost = defineDocumentType(() => ({
  name: 'BlogPost',
  filePathPattern: `blog/**/*.mdx`,
  contentType: 'mdx',
  fields: {
    title: {
      type: 'string',
      required: true
    },
    publishedAt: {
      type: 'date',
      required: true
    },
    description: {
      type: 'string',
      required: true,
    },
    image: {
      type: 'string',
    },
  },
  computedFields: {
    slug: {
      type: 'string',
      resolve: (doc) => doc._raw.flattenedPath.replace(new RegExp('^blog/?'), ''),
    },
    urlPath: {
      type: 'string',
      resolve: (doc) => `/blog/${doc._raw.flattenedPath.replace(new RegExp('^blog/?'), '')}`,
    },
  },
}))
 
module.exports = {
  types: {
    BlogPost: BlogPost,
  },
}

apps/gatographql.comapps/gatoplugins.com の両方の contentlayer.config.js でその型をインポートできます:

import { makeSource } from 'contentlayer2/source-files'
import ContentLayerConfig from '../../packages/shared/gatoapp/contentlayer.config.js'
 
const BlogPost = ContentLayerConfig.types.BlogPost
 
export default makeSource({
  documentTypes: [BlogPost],
})

通常、コードで型 BlogPost を参照する場合はこのようにインポートします:

import { BlogPost } from '@/.contentlayer/generated'

しかし、型 BlogPost はサイト側に存在し、共有パッケージには存在しないため、共有コードからその型を直接参照することはできません。

これをハックで解決します。コンパイル済みの Contentlayer ファイル(apps/gatographql/.contentlayer/generated/types.d.ts 配下)からその型定義をコピーし、共有パッケージの新しい types.tsx ファイルに貼り付けます:

import type { MDX, IsoDateTimeString } from 'contentlayer2/core'
 
export type BlogPost = {
  // _id: string // not needed
  // _raw: Local.RawDocumentData // not needed
  type: 'BlogPost'
  title: string
  publishedAt: IsoDateTimeString
  description: string
  image?: string | undefined
  body: MDX
  slug: string,
  urlPath: string,
}

そして共有コードでこの共有型を参照します:

import { BlogPost } from 'gatoapp/types'

サイト側と共有パッケージ側の BlogPost 型のプロパティが同じであるため、前者を後者を期待するコンポーネントに渡すことができます。

グローバルpropsを注入するためのコンテキストを作成する

ナビゲーションメニューコンポーネントは共有コードで表示されますが、各サイトが独自のメニューを持つため、サイト側のコードから提供する必要があります。

メニューはすべてのページに表示されるため、毎回propsとして渡したくありません。そこで Reactコンテキスト を使用し、ナビゲーションメニューコンポーネントを一度だけ注入できるようにします。

共有パッケージに AppComponent というコンテキストを作成します:

'use client'
 
import React from 'react'
import { createContext, useContext } from 'react'
import { StaticImageData } from 'next/image'
 
type ContextProps = {
  header: {
    menu: React.ReactNode,
    mobileMenu: React.ReactNode,
  },
}
 
const AppComponentContext = createContext<ContextProps>({
  header: {
    menu: <div></div>,
    mobileMenu: <div></div>,
  },
})
 
export interface AppComponentProviderInterface extends ContextProps {
  children: React.ReactNode,
}
 
export default function AppComponentProvider({
  children,
  header,
}: AppComponentProviderInterface) {  
  return (
    <AppComponentContext.Provider value={{ header }}>
      {children}
    </AppComponentContext.Provider>
  )
}
 
export const useAppComponentProvider = () => useContext(AppComponentContext)

共有パッケージ内で参照します:

'use client'
 
import Logo from './logo'
import HeaderMobile from './header-mobile'
import { useAppComponentProvider } from 'gatoapp/app/appcomponent-provider'
 
export default function Header() {
  const AppComponent = useAppComponentProvider()
  return (
    <header className="fixed w-full z-50">
      <div className={`absolute inset-0 bg-opacity-70 backdrop-blur -z-10 bg-white border-slate-200 border-b dark:border-b-0 dark:bg-transparent dark:border-slate-800`} aria-hidden="true"/>
      <div className="max-w-6xl mx-auto px-4 sm:px-6">
        <div className="flex items-center justify-between h-16">
 
          {/* Site branding */}
          <div className="flex-1">
            <Logo />
          </div>
 
          <nav className="hidden md:flex md:grow">
            {/* Desktop menu links */}
            {AppComponent.header.menu}
          </nav>
 
          <HeaderMobile />
 
        </div>
      </div>
    </header>
  )
}

そして apps/gatographql/app/(default)/layout.tsx でサイト側のコードから注入します:

import AppComponentProvider from 'gatoapp/app/appcomponent-provider'
import HeaderMenu from '@/components/menu/header-menu'
import HeaderMobileMenu from '@/components/menu/header-mobile-menu'
import DefaultLayout from 'gatoapp/app/(default)/layout'
 
export default function AppDefaultLayout({
  children,
}: {
  children: React.ReactNode
}) {  
  return (
    <AppComponentProvider
      header={{
        menu: <HeaderMenu />,
        mobileMenu: <HeaderMobileMenu />,
      }}
    >
      <DefaultLayout>
        {children}
      </DefaultLayout>
    </AppComponentProvider>
  )
}

最後に、サイト側が独自の HeaderMenu コンポーネントを実装します:

import Link from 'next/link'
import Dropdown from 'gatoapp/components/utils/dropdown'
 
export default function HeaderMenu() {
  return (
    <ul className="flex grow justify-center flex-wrap items-center">
      <li>
        <Link href="/pricing">Pricing</Link>
      </li>
      <li>
        <Link href='/extensions'>Extensions</Link>
      </li>      
      <Dropdown title="Product">
        <li>
          <Link href='/features'>Features</Link>
        </li>
        <li>
          <Link href='/highlights'>Highlights</Link>
        </li>
        <li>
          <Link href='/demos'>Demos</Link>
        </li>
        <li>
          <Link href='/comparisons'>Comparisons</Link>
        </li>
        <li>
          <Link href='/roadmap'>Roadmap</Link>
        </li>
      </Dropdown>
    </ul>
  )
}

ライトモードとダークモードのスタイル

Tailwindでは、ダークモード有効時に適用するクラスに dark: を前置します

そのため、共有パッケージのコードにはライトとダーク両方のバリアントのスタイルを含める必要があります。

たとえば、PageHeader コンポーネントはライトモード(text-gray-600)とダークモード(dark:text-slate-400)で異なる色を使って説明文を表示します:

export default function PageHeader({
  title,
  description,
  children,
}: {
  title: string,
  description?: string,
  children?: React.ReactNode,
}) {
  return (
    <div className="max-w-3xl mx-auto text-center">
      <h1 className="h1 pb-4">{title}</h1>
      {description && (
        <div className="max-w-3xl mx-auto">
          <p className="text-gray-600 dark:text-slate-400">{description}</p>
        </div>
      )}
      {children}
    </div>
  )
}

サイトのライトまたはダークモードを設定する

gatographql.com はダークモードを使用しています。apps/gatographql/app/layout.tsx ファイルの <body> にクラス名 dark を追加することで定義しています(スタイリング用クラス名 bg-slate-900 text-slate-100 も合わせて追加):

import { Inter } from 'next/font/google'
import RootLayoutHeader from 'gatoapp/app/layout-header'
 
const inter = Inter({
  subsets: ['latin'],
  variable: '--font-inter',
  display: 'swap'
})
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <RootLayoutHeader />
      <body className={`${inter.variable} dark bg-slate-900 text-slate-100`}>
        {children}
      </body>
    </html>
  )
}

gatoplugins.com はライトモードを使用しています。これはデフォルトモードなので、<body> に特別なクラス名を追加する必要はありません(スタイリング用クラス名 bg-white text-slate-700 のみ):

import { Inter } from 'next/font/google'
import RootLayoutHeader from 'gatoapp/app/layout-header'
 
const inter = Inter({
  subsets: ['latin'],
  variable: '--font-inter',
  display: 'swap'
})
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <RootLayoutHeader />
      <body className={`${inter.variable} bg-white text-slate-700`}>
        {children}
      </body>
    </html>
  )
}

まとめ

これで1サイト分のコストで2つのウェブサイトを手に入れました。とても満足しています。

さあ、7つの違いを探して、賞品をゲットしてください!😅


次に来るものをチェック

ニュースレターを購読しましょう。新バージョンのリリース、新しいプラグインの公開、お知らせがある際にお伝えします。