Blog

👭 Xây dựng 2 trang web Next.js với giá của 1, bằng cách phá đảo chế độ tối/sáng

Leonardo Losoviz
Bởi Leonardo Losoviz ·

Gần đây, đội ngũ Gato GraphQL đã ra mắt Gato Plugins, một trang web anh em của Gato GraphQL.

Bạn sẽ nhận ra 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 chủ đề tối, trong khi Gato Plugins sử dụng chủ đề 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 phần nội dung có sự khác biệt, tuy nhiên nền tảng bên dưới vẫn là như nhau.

Ví dụ, các tiện ích mở rộng của Gato GraphQL và các plugin của Gato Plugins đều sử dụng cùng một layout:

Phần tiện ích mở rộng trên gatographql.com
Phần tiện ích mở rộng 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 hai trang cũng gần như giống hệt 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 viết này cũng có mặt trên cả hai trang! 😂

Đọc trên gatoplugins.com: Building 2 Nextjs websites at the price of 1, by hacking the light/dark mode.

Tuy nhiên, có đúng 7 điểm khác biệt giữa các bài viết trên hai trang. Bạn có thể tìm ra tất cả không? Nếu tì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 sử 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 năng lượng để 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 có thể nhận ra chúng là một phần của cùng một gia đình.

Tôi không phải là một nhà thiết kế. Đã đạt được giao diện và phong cách đó, tôi rất 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. Nó giúp tôi tiết kiệm rất nhiều thời gian và công sức, để tôi có thể đầu tư vào sản phẩm của mình.

Nhược điểm là 2 trang không thể hỗ trợ chức năng chuyển đổi chế độ tối/sáng, nên giao diện 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 và xem nó đã được thực hiện như thế nào.

Stack: Ứng dụng được xây dựng trên 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 đẹp!)

Nội dung được quản lý thông qua Contentlayer.

Trích xuất code chung vào một package dùng chung, và đặt tất cả trong một monorepo

Vì codebase cho cả hai trang web là giống nhau, điều hợp lý là đặt chúng cùng nhau trong một monorepo.

Repository 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 dùng chung 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 gì cầu kỳ cho monorepo, một workspaces đơn giản là đủ.

File package.json ở gốc của monorepo giờ trông như thế này:

{
  "name": "gatowebsites",
  "version": "3.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ả 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 di chuyển code từ mỗi trang web vào package dùng chung, rồi tùy chỉnh hành vi thông qua props.

Ví dụ, package dùng chung gatoapp chứa một 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 = "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ừ:

  • Header trang (tiêu đề/mô tả)
  • Các bài viết blog
  • Banner chiến dịch

Vì hai trang web có thể chạy các chiến dịch của riêng mình một cách độc lập, việc truyền campaignBanner dưới dạng React.ReactNode không hạn chế việc tùy chỉnh các chiến dịch.

Ví dụ, khi tôi xuất bản bài viết 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

Để đưa các bài viết blog vào, cần thêm một chút logic hơn.

Đưa bài viết blog vào

Dữ liệu cho các bài viết blog được đưa vào BlogSection thông qua prop blogPosts.

Vì tôi đang dùng Contentlayer, mỗi trang web sẽ có một file contentlayer.config.js ở gốc, định nghĩa các type trên trang.

File cấu hình này không thể được chuyển vào package dùng chung gatoapp. Vì vậy, chúng tôi tạo một module export để cung cấp cấu hình cho các type dùng chung, rồi import chúng vào contentlayer.config.js của từng trang, giúp logic trở nên DRY.

gatoapp có module export contentlayer.config.js cung cấp type dùng chung 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 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],
})

Thông thường, để tham chiếu type BlogPost trong code, chúng ta sẽ import nó như thế này:

import { BlogPost } from '@/.contentlayer/generated'

Tuy nhiên, type BlogPost nằm dưới trang web, không phải dưới package dùng chung, vì vậy code dùng chung không thể trực tiếp tham chiếu type đó.

Chúng tôi giải quyết vấn đề này bằng một hack: Chúng tôi sao chép định nghĩa của type đó từ file Contentlayer đã biên dịch (dưới apps/gatographql/.contentlayer/generated/types.d.ts), và dán vào một file types.tsx mới trong package dùng chung:

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 type dùng chung này trong code dùng chung:

import { BlogPost } from 'gatoapp/types'

Vì các thuộc tính giữa các type BlogPost trên trang web và package dùng chung là giống nhau, chúng ta có thể truyền cái trước vào một component mong đợi cái sau.

Tạo context để đưa props toàn cục vào

Các component menu điều hướng sẽ được hiển thị trong code dùng chung, 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á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 mỗi lần. Vì vậy chúng tôi sử dụng React context, cho phép chúng tôi đưa các component menu điều hướng vào chỉ một lần.

Chúng tôi tạo một context gọi là AppComponent trong package dùng chung:

'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 dùng chung:

'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 đưa nó vào 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 của riêng 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="/gia">Pricing</Link>
      </li>
      <li>
        <Link href='/tien-ich'>Extensions</Link>
      </li>      
      <Dropdown title="Product">
        <li>
          <Link href='/tinh-nang'>Features</Link>
        </li>
        <li>
          <Link href='/noi-bat'>Highlights</Link>
        </li>
        <li>
          <Link href='/demos'>Demos</Link>
        </li>
        <li>
          <Link href='/so-sanh'>Comparisons</Link>
        </li>
      </Dropdown>
    </ul>
  )
}

Styles cho chế độ sáng và tối

Trong Tailwind, chúng ta thêm tiền tố dark: vào một class để sử dụng nó khi chế độ tối được bật.

Vì vậy, code trong package dùng chung phải chứa các style cho cả hai biến thể sáng và tối.

Ví dụ, component PageHeader hiển thị mô tả với các màu 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>
  )
}

Đặt 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 classname đặc biệt nào vào <body> (chỉ cần các classname để tạo kiểu: 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>
  )
}

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! 😅


Đăng ký nhận bản tin của chúng tôi

Cập nhật tất cả những điều mới từ Gato GraphQL.