👭 สร้างเว็บไซต์ Next.js 2 แห่งในราคาเดียว ด้วยการแฮ็กโหมดมืด/สว่าง
เมื่อเร็วๆ นี้ ทีมงาน Gato GraphQL ได้เปิดตัว Gato Plugins ซึ่งเป็นเว็บไซต์น้องของ Gato GraphQL
คุณจะสังเกตได้ว่าทั้งสองเว็บไซต์นั้นเหมือนกัน! ความแตกต่างเพียงอย่างเดียวระหว่างสองเว็บไซต์คือชุดสี: Gato GraphQL ใช้ธีมสีมืด ในขณะที่ Gato Plugins ใช้ธีมสีสว่าง
ส่วนบล็อกในทั้งสองเว็บไซต์นั้นเหมือนกันทุกประการ:


ส่วนเอกสารก็เหมือนกันด้วย:


บางส่วนอาจแตกต่างกัน แต่รากฐานที่อยู่เบื้องหลังนั้นเหมือนกัน
ตัวอย่างเช่น extensions ของ Gato GraphQL และปลั๊กอินของ Gato Plugins ใช้เลย์เอาต์เดียวกัน:


(อนึ่ง โลโก้ก็เกือบเหมือนกันด้วย! 😜)


และใช่ บทความบล็อกนี้ก็อยู่ในทั้งสองเว็บไซต์ด้วย! 😂
อ่านบน gatoplugins.com: Building 2 Nextjs websites at the price of 1, by hacking the light/dark mode.
อย่างไรก็ตาม มีความแตกต่างอยู่ 7 จุดระหว่างบทความในสองเว็บไซต์ คุณสามารถหาพบทั้งหมดได้ไหม? ถ้าทำได้ ผมจะมอบคูปองส่วนลดสำหรับ Gato GraphQL ให้ 🙏
เหตุใดเราจึงใช้โหมดสว่าง/มืดเพื่อสร้าง 2 เว็บไซต์
มีเหตุผลหลายประการ:
ผมไม่มีเวลาหรือพลังงานพอที่จะดูแลโค้ดเบสสองชุดแยกกัน ต้องทำให้ทุกอย่างเรียบง่าย.
ทุกชั่วโมงที่ผมใช้กับเว็บไซต์คือชั่วโมงที่ผมไม่ได้ใช้กับผลิตภัณฑ์ใดผลิตภัณฑ์หนึ่งของผม
ผมต้องการให้พวกมันดูคล้ายกัน เพื่อที่ผู้ใช้จะได้รับรู้ว่าพวกมันเป็นส่วนหนึ่งของครอบครัวเดียวกัน
ผมไม่ใช่นักออกแบบ เมื่อได้รูปลักษณ์และสไตล์นั้นมา ผมพอใจแล้ว และไม่ต้องการเริ่มต้นใหม่ตั้งแต่ศูนย์.
พูดอีกนัยหนึ่ง: เพราะมันถูกและง่าย มันช่วยประหยัดเวลาและพลังงานของผมได้มหาศาล ซึ่งผมสามารถนำไปใช้กับผลิตภัณฑ์ของตัวเองได้
ข้อเสียคือทั้ง 2 เว็บไซต์ไม่สามารถรองรับการสลับโหมดมืด/สว่างได้ ทำให้สไตล์ถูกกำหนดตายตัว แต่นั่นเป็นสิ่งที่ผมรับได้
เอาล่ะ! มาดูกันว่ามันทำได้อย่างไร
Stack: แอปพลิเคชันสร้างบน Next.js และใช้ Tailwind CSS สำหรับการจัดสไตล์
สร้างขึ้นจากการรวมเทมเพลตหลายชิ้นจาก Cruip ที่ปรับแต่งตามความต้องการของเรา (เทมเพลตเหล่านั้นสวยงามมาก!)
เนื้อหาจัดการผ่าน Contentlayer.
แยกโค้ดส่วนกลางออกเป็น shared package และโฮสต์ทุกอย่างใน monorepo
เนื่องจากโค้ดเบสของทั้งสองเว็บไซต์เหมือนกัน จึงสมเหตุสมผลที่จะโฮสต์ทุกอย่างไว้ใน monorepo ด้วยกัน
เดิมทีรีโพซิทอรีของผมมีแค่โปรเจกต์เดียว:
- gatographql.com
ได้ถูกปรับโครงสร้างใหม่เป็นดังนี้:
- apps/gatographql.com: เว็บไซต์ Gato GraphQL
- apps/gatoplugins.com: เว็บไซต์ Gato Plugins
- packages/shared/gatoapp: โค้ดที่แชร์ร่วมกันระหว่างทั้งสองเว็บไซต์
นี่คือ workspace ของผมใน VSCode:

ผมไม่ได้ใช้เครื่องมือพิเศษสำหรับ monorepo เพียงแค่ workspaces ธรรมดาก็ทำงานได้ดี
package.json ที่รากของ monorepo ปัจจุบันมีหน้าตาแบบนี้:
{
"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"
}
}แปลง components ให้รับ props สำหรับข้อมูลที่กำหนดเอง
เท่าที่จะเป็นไปได้ เราย้ายโค้ดจากแต่ละเว็บไซต์ไปยัง shared package แล้วปรับแต่งพฤติกรรมผ่าน props
ตัวอย่างเช่น shared package gatoapp มี component 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>
)
}เนื้อหาทั้งหมดเหมือนกัน ยกเว้น:
- ส่วนหัวของหน้า (title/description)
- บทความบล็อก
- แบนเนอร์แคมเปญ
เนื่องจากทั้งสองเว็บไซต์สามารถดำเนินแคมเปญของตัวเองได้อย่างอิสระ การส่ง campaignBanner เป็น React.ReactNode จึงไม่จำกัดการปรับแต่งแคมเปญ
ตัวอย่างเช่น ขณะที่ผมเผยแพร่บทความบล็อกนี้ ผมกำลังดำเนินแคมเปญใน Gato GraphQL แต่ไม่ใช่ใน Gato Plugins:

การ inject บทความบล็อกต้องใช้ logic เพิ่มเติมอีกเล็กน้อย
การ Inject บทความบล็อก
ข้อมูลสำหรับบทความบล็อกถูก inject ไปยัง BlogSection ผ่าน prop blogPosts
เนื่องจากผมใช้ Contentlayer แต่ละเว็บไซต์จะมีไฟล์ contentlayer.config.js ที่รากไดเรกทอรี ซึ่งกำหนด type บนเว็บไซต์นั้น
ไฟล์คอนฟิกนี้ไม่สามารถย้ายไปยัง shared gatoapp ได้ ดังนั้นเราจึงสร้าง export module เพื่อให้การกำหนดค่าสำหรับ type ที่ใช้ร่วมกัน แล้วนำเข้าในไฟล์ contentlayer.config.js ของแต่ละเว็บไซต์ ทำให้ logic เป็น DRY
gatoapp มี export module contentlayer.config.js ที่จัดเตรียม type ที่ใช้ร่วมกัน BlogPost:
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,
},
}ไฟล์ contentlayer.config.js ทั้งใน apps/gatographql.com และ apps/gatoplugins.com สามารถนำเข้า type นั้นได้:
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],
})โดยปกติ ในการอ้างอิง type BlogPost ในโค้ด เราจะ import แบบนี้:
import { BlogPost } from '@/.contentlayer/generated'อย่างไรก็ตาม type BlogPost อยู่ภายใต้เว็บไซต์ ไม่ใช่ภายใต้ shared package ดังนั้น shared code จึงไม่สามารถอ้างอิง type นั้นโดยตรงได้
เราแก้ปัญหานี้ด้วยการแฮ็ก: เราคัดลอกคำจำกัดความของ type นั้นจากไฟล์ Contentlayer ที่คอมไพล์แล้ว (ภายใต้ apps/gatographql/.contentlayer/generated/types.d.ts) แล้ววางลงในไฟล์ types.tsx ใหม่ใน shared package:
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,
}จากนั้นเราอ้างอิง shared type นี้ใน shared code:
import { BlogPost } from 'gatoapp/types'เนื่องจาก properties ระหว่าง type BlogPost ในเว็บไซต์และ shared package เหมือนกัน เราจึงสามารถส่งอันแรกไปยัง component ที่คาดหวังอันหลังได้
สร้าง context เพื่อ inject global props
Navigation menu components จะถูกแสดงบน shared code แต่ต้องจัดหาผ่าน website code เนื่องจากแต่ละเว็บไซต์จะมีเมนูของตัวเอง
เมนูปรากฏในทุกหน้า และเราไม่ต้องการส่งผ่าน props ซ้ำแล้วซ้ำเล่า ดังนั้นเราใช้ React context ซึ่งทำให้เรา inject navigation menu components ได้เพียงครั้งเดียว
เราสร้าง context ชื่อ AppComponent ใน shared package:
'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)เราอ้างอิงมันใน shared package ของเรา:
'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>
)
}และเรา inject มันผ่าน website code ใน 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>
)
}สุดท้าย เว็บไซต์จะ implement component 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: นำหน้าคลาส เพื่อใช้เมื่อโหมดมืดเปิดใช้งาน
ดังนั้น shared package code ของเราต้องประกอบด้วยสไตล์สำหรับทั้งโหมดสว่างและมืด
ตัวอย่างเช่น component PageHeader แสดง description ด้วยสีที่แตกต่างกันสำหรับโหมดสว่าง (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 ใช้โหมดมืด กำหนดโดยการเพิ่ม classname dark ให้กับ <body> ในไฟล์ apps/gatographql/app/layout.tsx (พร้อมกับ classname สำหรับการจัดสไตล์: 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 ใช้โหมดสว่าง นี่คือโหมดเริ่มต้น ดังนั้นจึงไม่จำเป็นต้องเพิ่ม classname เฉพาะให้กับ <body> (เฉพาะ classname สำหรับการจัดสไตล์: 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>
)
}เพียงเท่านี้
ตอนนี้ผมมี 2 เว็บไซต์ที่ได้มาในราคาเดียว และผมพอใจกับมันมาก
ตอนนี้ ไปหาความแตกต่าง 7 จุด แล้วรับรางวัลของคุณ! 😅