Blog

👭 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

Leonardo Losoviz
Bởi Leonardo Losoviz ·

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 blog trên gatographql.com
Phần blog trên gatographql.com
Phần blog trên gatoplugins.com
Phần blog trên gatoplugins.com

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

Phần tài liệu trên gatographql.com
Phần tài liệu trên gatographql.com
Phần tài liệu trên gatoplugins.com
Phần tài liệu trên gatoplugins.com

Đô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:

Phần extension trên gatographql.com
Phần extension trên gatographql.com
Phần plugin trên gatoplugins.com
Phần plugin trên gatoplugins.com

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

Logo trên gatographql.com
Logo trên gatographql.com
Logo trên gatoplugins.com
Logo trên gatoplugins.com

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:

Cấu trúc monorepo của tôi
Cấu trúc monorepo của tôi

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:

Banner chiến dịch trên gatographql.com
Banner chiến dịch trên gatographql.com

Để 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.comapps/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! 😅


Khám phá những điều sắp tới

Đăng ký nhận bản tin của chúng tôi: Nhận thông báo khi chúng tôi phát hành phiên bản mới, ra mắt plugin mới hoặc có tin tức muốn chia sẻ với bạn.