Blog

๐Ÿ‘ญ Membangun 2 situs Next.js dengan harga 1, dengan meretas mode terang/gelap

Leonardo Losoviz
Oleh Leonardo Losoviz ยท

Baru-baru ini tim Gato GraphQL meluncurkan Gato Plugins, sebuah situs saudara dari Gato GraphQL.

Anda akan melihat bahwa keduanya adalah situs yang sama! Satu-satunya perbedaan antara keduanya adalah skema warna: Gato GraphQL bertema gelap, sementara Gato Plugins bertema terang.

Bagian blog di kedua situs benar-benar sama:

Bagian blog di gatographql.com
Bagian blog di gatographql.com
Bagian blog di gatoplugins.com
Bagian blog di gatoplugins.com

Bagian docs juga sama:

Bagian docs di gatographql.com
Bagian docs di gatographql.com
Bagian docs di gatoplugins.com
Bagian docs di gatoplugins.com

Kadang bagiannya berbeda, namun fondasi yang mendasarinya tetap sama.

Misalnya, ekstensi Gato GraphQL dan plugin Gato Plugins menggunakan layout yang sama:

Bagian ekstensi di gatographql.com
Bagian ekstensi di gatographql.com
Bagian plugin di gatoplugins.com
Bagian plugin di gatoplugins.com

(Omong-omong, logonya pun hampir sama! ๐Ÿ˜œ)

Logo di gatographql.com
Logo di gatographql.com
Logo di gatoplugins.com
Logo di gatoplugins.com

Ya, postingan blog ini juga ada di kedua situs! ๐Ÿ˜‚

Baca di gatographql.com: Building 2 Nextjs websites at the price of 1, by hacking the dark/light mode.

Namun, ada tepat 7 perbedaan antara postingan di dua situs itu. Bisakah Anda menemukan semuanya? Jika bisa, saya akan memberi Anda kupon diskon untuk Gato GraphQL ๐Ÿ™

Mengapa kami menggunakan mode terang/gelap untuk menghasilkan 2 situs web

Ada beberapa alasan:

Saya tidak punya waktu atau tenaga untuk memelihara dua codebase terpisah. Saya perlu menjaga segalanya tetap sederhana.

Setiap jam yang saya habiskan di situs web adalah satu jam yang tidak saya habiskan untuk salah satu produk saya.

Saya ingin keduanya terlihat serupa, sehingga pengguna dapat mengenali mereka sebagai bagian dari keluarga yang sama.

Saya bukan seorang desainer. Setelah berhasil mencapai tampilan dan gaya itu, saya puas, dan saya tidak ingin memulai dari awal.

Dengan kata lain: karena murah dan mudah. Ini menghemat banyak waktu dan energi yang bisa saya gunakan untuk produk saya sendiri.

Sebagai kelemahannya, kedua situs tidak dapat mendukung toggle mode terang/gelap, sehingga gayanya tetap, tapi itu sesuatu yang bisa saya terima.


Baiklah! Mari kita mulai, dan lihat bagaimana cara melakukannya.

Stack: Aplikasi ini dibangun di atas Next.js, dan Tailwind CSS untuk styling.

Dibuat sebagai kombinasi dari beberapa template oleh Cruip, yang disesuaikan dengan kebutuhan kami. (Template-template itu sangat indah!)

Konten dikelola melalui Contentlayer.

Ekstrak kode umum ke dalam paket bersama, dan simpan semuanya dalam satu monorepo

Karena codebase untuk kedua situs sama, masuk akal untuk menyimpan semuanya bersama dalam satu monorepo.

Repositori saya awalnya hanya memiliki satu proyek:

  • gatographql.com

Kemudian distrukturisasi ulang menjadi berikut:

  • apps/gatographql.com: Situs web Gato GraphQL
  • apps/gatoplugins.com: Situs web Gato Plugins
  • packages/shared/gatoapp: Kode bersama antara kedua situs web

Ini adalah workspace saya di VSCode:

Struktur monorepo saya
Struktur monorepo saya

Saya tidak menggunakan sesuatu yang mewah untuk monorepo, sebuah workspaces sederhana sudah cukup berhasil.

package.json saya di root monorepo sekarang terlihat seperti ini:

{
  "name": "gatowebsites",
  "version": "2.0.0",
  "private": true,
  "workspaces": [
    "apps/*",
    "packages/shared/*"
  ]
}

Selain itu, saya menambahkan skrip ke package.json untuk menjalankan/membangun/mendeploy kedua proyek (termasuk deploy ke Netlify, tempat keduanya dihosting):

{
  "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"
  }
}

Ubah komponen agar menerima props untuk data kustom

Sebisa mungkin, kami memindahkan kode dari masing-masing situs web ke dalam paket bersama, kemudian kami mengkustomisasi perilakunya melalui props.

Misalnya, paket bersama gatoapp berisi komponen BlogSection (untuk menampilkan halaman /blog di kedua situs):

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>
  )
}

Semua konten sama, kecuali:

  • Header halaman (judul/deskripsi)
  • Postingan blog
  • Banner kampanye

Karena kedua situs web dapat menjalankan kampanye mereka sendiri secara independen, meneruskan campaignBanner sebagai React.ReactNode tidak membatasi kustomisasi kampanye.

Misalnya, saat saya menerbitkan postingan blog ini, saya sedang menjalankan kampanye di Gato GraphQL, tapi tidak di Gato Plugins:

Banner kampanye di gatographql.com
Banner kampanye di gatographql.com

Untuk menyuntikkan postingan blog, diperlukan sedikit lebih banyak logika.

Menyuntikkan postingan blog

Data untuk postingan blog disuntikkan ke BlogSection melalui prop blogPosts.

Karena saya menggunakan Contentlayer, setiap situs web akan memiliki file contentlayer.config.js di root, yang mendefinisikan tipe-tipe di situs.

File konfigurasi ini tidak dapat dipindahkan ke gatoapp yang bersama. Kemudian, kami membuat modul ekspor untuk menyediakan konfigurasi tipe-tipe bersama, lalu mengimpornya di contentlayer.config.js untuk setiap situs, menjaga logikanya tetap DRY.

gatoapp memiliki modul ekspor contentlayer.config.js yang menyediakan tipe bersama 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 di apps/gatographql.com maupun apps/gatoplugins.com kemudian dapat mengimpor tipe tersebut:

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],
})

Biasanya, untuk mereferensikan tipe BlogPost dalam kode kita, kita akan mengimpornya seperti ini:

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

Namun, tipe BlogPost berada di bawah situs web, bukan di bawah paket bersama, sehingga kode bersama tidak dapat mereferensikan tipe tersebut secara langsung.

Kami mengatasi ini dengan sebuah trik: Kami menyalin definisi tipe tersebut dari file Contentlayer yang dikompilasi (di bawah apps/gatographql/.contentlayer/generated/types.d.ts), dan menempelkannya ke file types.tsx baru di paket bersama:

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,
}

Kemudian kami mereferensikan tipe bersama ini dalam kode bersama:

import { BlogPost } from 'gatoapp/types'

Karena properti antara tipe BlogPost di situs web dan paket bersama sama, kita dapat meneruskan yang pertama ke komponen yang mengharapkan yang kedua.

Buat konteks untuk menyuntikkan props global

Komponen menu navigasi akan ditampilkan dalam kode bersama, tetapi perlu disediakan melalui kode situs web, karena setiap situs web akan memiliki menu-nya sendiri.

Menu muncul di semua halaman, dan kami tidak ingin harus meneruskannya melalui props berkali-kali. Jadi kami menggunakan sebuah konteks React, yang memungkinkan kami menyuntikkan komponen menu navigasi hanya sekali.

Kami membuat konteks bernama AppComponent di paket bersama:

'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)

Kami mereferensikannya dalam paket bersama kami:

'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>
  )
}

Dan kami menyuntikkannya melalui kode situs web, di 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>
  )
}

Terakhir, situs web mengimplementasikan komponen HeaderMenu-nya sendiri:

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>
  )
}

Gaya untuk mode terang dan gelap

Di Tailwind, kami menambahkan awalan dark: pada sebuah kelas untuk menggunakannya saat mode gelap diaktifkan.

Kemudian, kode paket bersama kami harus berisi gaya untuk kedua varian terang dan gelap.

Misalnya, komponen PageHeader menampilkan deskripsi dengan warna berbeda untuk mode terang (text-gray-600) dan mode gelap (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>
  )
}

Mengatur mode terang atau gelap pada situs

gatographql.com menggunakan mode gelap. Mode ini didefinisikan dengan menambahkan classname dark ke <body> di file apps/gatographql/app/layout.tsx (ditambah classname untuk styling: 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 menggunakan mode terang. Ini adalah mode default, sehingga tidak perlu menambahkan classname khusus apa pun ke <body> (hanya yang untuk styling: 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>
  )
}

Selesai

Saya sekarang memiliki 2 situs web, yang saya dapatkan dengan harga 1. Dan saya sangat senang dengan itu.

Sekarang, temukan 7 perbedaannya, dan dapatkan hadiahmu! ๐Ÿ˜…


Cari tahu apa yang akan datang

Berlangganan buletin kami: Ketahui kapan kami merilis versi baru, meluncurkan plugin baru, atau memiliki kabar untuk dibagikan kepada Anda.