ブログ

👭 ダーク/ライトモードを活用して、1つの価格で2つのNext.jsウェブサイトを構築する

Leonardo Losoviz
著者: Leonardo Losoviz ·

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

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

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

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

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

gatographql.comのDocsセクション
gatographql.comのDocsセクション
gatoplugins.comのDocsセクション
gatoplugins.comのDocsセクション

セクションが異なる場合でも、基盤となる土台は同じです。

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

gatographql.comのExtensionsセクション
gatographql.comのExtensionsセクション
gatoplugins.comのPluginsセクション
gatoplugins.comのPluginsセクション

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

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

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

gatoplugins.comで読む: Building 2 Nextjs websites at the price of 1, by hacking the light/dark mode.

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

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

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

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

ウェブサイトに費やす1時間は、どちらかのプロダクトに使えない1時間です。

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

私はデザイナーではありません。あのルックとスタイルを実現できて満足しており、一からやり直したくありませんでした

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

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


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

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

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

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

共通コードを共有パッケージに抽出し、モノレポにまとめる

両サイトのコードベースが同じであるため、モノレポにまとめてホストするのが自然な選択です。

元々のリポジトリには単一のプロジェクトしかありませんでした。

  • gatographql.com

これを以下の構成に再編しました。

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

これが VSCode でのワークスペースです。

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

モノレポに凝ったツールは使っておらず、シンプルな workspaces で十分機能します。

モノレポのルートにある package.json は現在このようになっています。

{
  "name": "gatowebsites",
  "version": "3.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 = "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 propを通じて 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>
      </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-800 のみです)。

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-800`}>
        {children}
      </body>
    </html>
  )
}

以上です

1つの価格で2つのウェブサイトを手に入れました。そして、それにとても満足しています。

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


ニュースレターを購読する

Gato GraphQL のすべてのアップデートを把握しましょう。