Blog

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

Leonardo Losoviz
Oleh Leonardo Losoviz ยท

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

Anda akan menyadari 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 tersebut persis sama:

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

Bagian dokumentasi juga sama:

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

Terkadang bagiannya berbeda, namun fondasi dasarnya tetap sama.

Misalnya, extensions Gato GraphQL dan plugins Gato Plugins menggunakan layout yang sama:

Bagian extensions di gatographql.com
Bagian extensions di gatographql.com
Bagian plugins di gatoplugins.com
Bagian plugins 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

Dan ya, posting blog ini juga ada di kedua situs! ๐Ÿ˜‚

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

Namun, ada tepat 7 perbedaan antara posting di kedua situs tersebut. Bisakah Anda menemukannya semua? 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 energi untuk memelihara dua codebase terpisah. Saya perlu menjaga semuanya tetap sederhana.

Setiap jam yang saya habiskan untuk situs web adalah jam yang tidak saya habiskan untuk 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 tersebut, saya merasa puas, dan tidak ingin memulai dari awal.

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

Sebagai kekurangannya, 2 situs tersebut tidak dapat mendukung toggle mode gelap/terang, sehingga gayanya tetap, namun itu adalah sesuatu yang bisa saya terima.


Baiklah! Mari kita mulai bekerja dan lihat bagaimana hal ini dilakukan.

Stack: Aplikasi ini berbasis 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 bersama ke dalam paket shared, dan host semuanya dalam sebuah monorepo

Karena codebase untuk kedua situs web sama, sangatlah masuk akal untuk menghostingnya bersama-sama dalam sebuah monorepo.

Repo saya awalnya memiliki satu proyek tunggal:

  • gatographql.com

Kemudian direstrukturisasi 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 apapun yang rumit untuk monorepo, sebuah workspaces sederhana sudah cukup.

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

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

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

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

Konversi komponen untuk menerima props dengan data kustom

Sebisa mungkin, kami memindahkan kode dari masing-masing situs web ke dalam paket shared, lalu menyesuaikan perilakunya melalui props.

Misalnya, paket shared 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 = "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 untuk:

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

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

Misalnya, saat saya mempublikasikan posting blog ini, saya sedang menjalankan kampanye di Gato GraphQL, tetapi tidak di Gato Plugins:

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

Untuk menyuntikkan posting blog, diperlukan sedikit lebih banyak logika.

Menyuntikkan posting blog

Data untuk posting 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 pada situs tersebut.

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

gatoapp memiliki modul ekspor contentlayer.config.js yang menyediakan tipe shared 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 kami, kami akan mengimpornya seperti ini:

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

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

Kami memecahkan ini dengan sebuah hack: Kami menyalin definisi untuk tipe tersebut dari file Contentlayer yang sudah dikompilasi (di apps/gatographql/.contentlayer/generated/types.d.ts), dan menempelkannya ke dalam file types.tsx baru di paket shared:

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 shared ini dalam kode shared:

import { BlogPost } from 'gatoapp/types'

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

Membuat context untuk menyuntikkan props global

Komponen menu navigasi akan dirender dalam kode shared, tetapi harus 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 berulang kali. Jadi kami menggunakan sebuah React context, yang memungkinkan kami menyuntikkan komponen menu navigasi hanya sekali.

Kami membuat context bernama AppComponent di paket shared:

'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 di paket shared 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>
  )
}

Akhirnya, 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="/harga">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>
  )
}

Gaya untuk mode terang dan gelap

Di Tailwind, kami membubuhkan kelas dengan dark: untuk menggunakannya saat mode gelap diaktifkan.

Kemudian, kode paket shared kami harus mengandung 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. 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-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>
  )
}

Itulah semuanya

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

Sekarang, pergi temukan 7 perbedaannya, dan dapatkan hadiah Anda! ๐Ÿ˜…


Berlangganan newsletter kami

Tetap update dengan semua pembaruan Gato GraphQL.