👭 Xây dựng 2 trang web Next.js với giá của 1, bằng cách khai thác chế độ sáng/tối
Gần đây nhóm Gato GraphQL đã ra mắt Gato Plugins, một trang web anh em của Gato GraphQL.
Bạn sẽ nhận thấy rằng cả hai đều là cùng một trang web! Điểm khác biệt duy nhất giữa hai trang là bảng màu: Gato GraphQL sử dụng giao diện tối, trong khi Gato Plugins sử dụng giao diện sáng.
Phần blog trên cả hai trang hoàn toàn giống nhau:


Phần tài liệu cũng giống nhau:


Đôi khi một số phần có sự khác biệt, tuy nhiên nền tảng cơ bản vẫn là như nhau.
Ví dụ, các extension của Gato GraphQL và các plugin của Gato Plugins sử dụng cùng một layout:


(Nhân tiện, logo của cả hai cũng gần như giống nhau! 😜)


Và đúng vậy, bài đăng này cũng có mặt trên cả hai trang! 😂
Đọc trên gatographql.com: Building 2 Nextjs websites at the price of 1, by hacking the dark/light mode.
Tuy nhiên, có đúng 7 điểm khác biệt giữa hai bài đăng trên hai trang. Bạn có thể tìm ra tất cả không? Nếu làm được, tôi sẽ tặng bạn một phiếu giảm giá cho Gato GraphQL 🙏
Tại sao chúng tôi dùng chế độ sáng/tối để tạo ra 2 trang web
Có nhiều lý do:
Tôi không có đủ thời gian hay sức lực để duy trì hai codebase riêng biệt. Tôi cần giữ mọi thứ đơn giản.
Mỗi giờ tôi dành cho trang web là một giờ tôi không dành cho sản phẩm của mình.
Tôi muốn chúng trông giống nhau, để người dùng nhận ra chúng thuộc cùng một gia đình.
Tôi không phải là nhà thiết kế. Đã đạt được giao diện và phong cách đó, tôi hài lòng và không muốn bắt đầu lại từ đầu.
Nói cách khác: vì nó rẻ và dễ dàng. Điều đó giúp tôi tiết kiệm rất nhiều thời gian và công sức, để tôi có thể tập trung vào sản phẩm của mình.
Một nhược điểm là 2 trang web không thể hỗ trợ nút chuyển đổi chế độ sáng/tối, nên phong cách của chúng là cố định, nhưng đó là điều tôi có thể chấp nhận được.
Được rồi! Vậy hãy bắt tay vào việc, và xem cách nó được thực hiện như thế nào.
Stack: Ứng dụng được xây dựng trên nền tảng Next.js, và Tailwind CSS để tạo kiểu.
Nó được tạo ra từ sự kết hợp của nhiều template của Cruip, được tùy chỉnh theo nhu cầu của chúng tôi. (Những template đó thật tuyệt đẹp!)
Nội dung được quản lý thông qua Contentlayer.
Trích xuất code chung vào một package chia sẻ, và lưu trữ mọi thứ trong một monorepo
Vì codebase cho cả hai trang web là giống nhau, việc lưu trữ chúng cùng nhau trong một monorepo là hoàn toàn hợp lý.
Repo của tôi ban đầu chỉ có một dự án:
- gatographql.com
Nó được tái cấu trúc thành như sau:
- apps/gatographql.com: Trang web Gato GraphQL
- apps/gatoplugins.com: Trang web Gato Plugins
- packages/shared/gatoapp: Code chia sẻ giữa cả hai trang web
Đây là workspace của tôi trong VSCode:

Tôi không dùng bất kỳ công cụ phức tạp nào cho monorepo, một workspaces đơn giản là đủ.
File package.json của tôi ở thư mục gốc của monorepo trông như thế này:
{
"name": "gatowebsites",
"version": "2.0.0",
"private": true,
"workspaces": [
"apps/*",
"packages/shared/*"
]
}Ngoài ra, tôi đã thêm các script vào package.json để chạy/build/deploy cả hai dự án (bao gồm cả việc deploy lên Netlify, nơi cả hai đều được lưu trữ):
{
"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"
}
}Chuyển đổi các component để nhận props cho dữ liệu tùy chỉnh
Càng nhiều càng tốt, chúng tôi chuyển code từ mỗi trang web vào package chia sẻ, rồi tùy chỉnh hành vi thông qua props.
Ví dụ, package chia sẻ gatoapp chứa component BlogSection (để hiển thị trang /blog trên cả hai trang):
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>
)
}Tất cả nội dung đều giống nhau, ngoại trừ:
- Tiêu đề trang (tiêu đề/mô tả)
- Các bài đăng blog
- Banner chiến dịch
Vì hai trang web có thể chạy các chiến dịch riêng độc lập với nhau, việc truyền campaignBanner như một React.ReactNode không hạn chế việc tùy chỉnh các chiến dịch.
Ví dụ, khi tôi đăng bài này, tôi đang chạy một chiến dịch trên Gato GraphQL, nhưng không phải trên Gato Plugins:

Để chèn các bài đăng blog, cần thêm một chút logic.
Chèn các bài đăng blog
Dữ liệu cho các bài đăng blog được chèn vào BlogSection thông qua prop blogPosts.
Vì tôi đang sử dụng Contentlayer, mỗi trang web sẽ có một file contentlayer.config.js ở thư mục gốc, định nghĩa các kiểu dữ liệu trên trang.
File cấu hình này không thể chuyển vào package chia sẻ gatoapp. Vì vậy, chúng tôi tạo một module xuất để cung cấp cấu hình cho các kiểu chia sẻ, rồi import chúng vào contentlayer.config.js của từng trang, giữ cho logic DRY.
gatoapp có module xuất contentlayer.config.js cung cấp kiểu chia sẻ 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,
},
}File contentlayer.config.js trong cả apps/gatographql.com và apps/gatoplugins.com có thể import kiểu đó:
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],
})Thông thường, để tham chiếu kiểu BlogPost trong code của chúng tôi, chúng tôi sẽ import nó như thế này:
import { BlogPost } from '@/.contentlayer/generated'Tuy nhiên, kiểu BlogPost nằm trong trang web, không phải trong package chia sẻ, vì vậy code chia sẻ không thể trực tiếp tham chiếu kiểu đó.
Chúng tôi giải quyết vấn đề này bằng một cách khéo léo: Chúng tôi sao chép định nghĩa cho kiểu đó từ file Contentlayer đã được compile (trong apps/gatographql/.contentlayer/generated/types.d.ts), và dán nó vào một file types.tsx mới trong package chia sẻ:
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,
}Sau đó chúng tôi tham chiếu kiểu chia sẻ này trong code chia sẻ:
import { BlogPost } from 'gatoapp/types'Vì các thuộc tính giữa kiểu BlogPost trong trang web và package chia sẻ là giống nhau, chúng tôi có thể truyền cái trước vào một component mong đợi cái sau.
Tạo một context để chèn props toàn cục
Các component menu điều hướng sẽ được hiển thị trong code chia sẻ, nhưng chúng cần được cung cấp thông qua code của trang web, vì mỗi trang web sẽ có menu riêng của mình.
Các menu xuất hiện trên tất cả các trang, và chúng tôi không muốn phải truyền chúng qua props đi lại nhiều lần. Vì vậy, chúng tôi sử dụng React context, cho phép chúng tôi chèn các component menu điều hướng chỉ một lần.
Chúng tôi tạo một context gọi là AppComponent trong package chia sẻ:
'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)Chúng tôi tham chiếu nó trong package chia sẻ của mình:
'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>
)
}Và chúng tôi chèn nó thông qua code của trang web, trong 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>
)
}Cuối cùng, trang web triển khai component HeaderMenu riêng của mình:
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>
)
}Kiểu dáng cho chế độ sáng và tối
Trong Tailwind, chúng tôi thêm tiền tố dark: vào một class để sử dụng khi chế độ tối được bật.
Vì vậy, code trong package chia sẻ của chúng tôi phải chứa các kiểu dáng cho cả hai biến thể sáng và tối.
Ví dụ, component PageHeader hiển thị mô tả với màu sắc khác nhau cho chế độ sáng (text-gray-600) và chế độ tối (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>
)
}Thiết lập chế độ sáng hoặc tối trên trang web
gatographql.com sử dụng chế độ tối. Nó được định nghĩa bằng cách thêm classname dark vào <body> trong file apps/gatographql/app/layout.tsx (cùng với các classname tạo kiểu: 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 sử dụng chế độ sáng. Đây là chế độ mặc định, vì vậy không cần thêm bất kỳ classname đặc biệt nào vào <body> (chỉ có các classname tạo kiểu: 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>
)
}Vậy là xong
Bây giờ tôi có 2 trang web với giá của 1. Và tôi rất hài lòng với điều đó.
Bây giờ, hãy đi tìm 7 điểm khác biệt và nhận phần thưởng của bạn! 😅