👭 ダーク/ライトモードを活用して、1つの価格で2つのNext.jsウェブサイトを構築する
最近、Gato GraphQL チームは Gato Plugins をリリースしました。これは Gato GraphQL の兄弟サイトです。
両サイトが同じサイトであることにお気づきでしょう! 2つの違いはカラースキームだけです。Gato GraphQL はダークテーマを採用し、Gato Plugins はライトテーマを採用しています。
両サイトのブログセクションはまったく同じです。


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


セクションが異なる場合でも、基盤となる土台は同じです。
たとえば、Gato GraphQL のextensionsと Gato Plugins のプラグインは同じレイアウトを使用しています。


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


そして、このブログ記事も両方のサイトに掲載されています! 😂
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つのウェブサイトが独立してキャンペーンを実施できるよう、campaignBanner を React.ReactNode として渡すことでキャンペーンのカスタマイズを制限しません。
たとえば、このブログ記事を公開している時点では、Gato GraphQL でキャンペーンを実施していますが、Gato Plugins では実施していません。

ブログ記事を注入するには、もう少し多くのロジックが必要です。
ブログ記事の注入
ブログ記事のデータは 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.com と apps/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つの違いを探して、賞品をゲットしてください! 😅