Blog

πŸ‘­ 2 Next.js-websites bouwen voor de prijs van 1, door de lichte/donkere modus te hacken

Leonardo Losoviz
Door Leonardo Losoviz Β·

Onlangs heeft het Gato GraphQL-team Gato Plugins gelanceerd, een zustersite van Gato GraphQL.

Je zult merken dat het allebei dezelfde site is! Het enige verschil tussen de twee is het kleurenschema: Gato GraphQL heeft een donker thema, terwijl Gato Plugins een licht thema heeft.

De blogsectie op beide sites is precies hetzelfde:

Blogsectie op gatographql.com
Blogsectie op gatographql.com
Blogsectie op gatoplugins.com
Blogsectie op gatoplugins.com

De documentatiesectie is ook hetzelfde:

Docs-sectie op gatographql.com
Docs-sectie op gatographql.com
Docs-sectie op gatoplugins.com
Docs-sectie op gatoplugins.com

Soms is de sectie anders, maar de onderliggende basis is hetzelfde.

Zo gebruiken Gato GraphQL-extensies en Gato Plugins-plugins dezelfde indeling:

Extensiesectie op gatographql.com
Extensiesectie op gatographql.com
Pluginssectie op gatoplugins.com
Pluginssectie op gatoplugins.com

(Trouwens, ook de logo's zijn vrijwel hetzelfde! 😜)

Logo op gatographql.com
Logo op gatographql.com
Logo op gatoplugins.com
Logo op gatoplugins.com

En ja, dit blogartikel staat ook op beide sites! πŸ˜‚

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

Er zijn echter precies 7 verschillen tussen de artikelen op de twee sites. Kun je ze allemaal vinden? Als dat lukt, geef ik je een kortingsbon voor Gato GraphQL πŸ™

Waarom we de lichte/donkere modi hebben gebruikt om 2 websites te maken

Er zijn meerdere redenen:

Ik heb niet de tijd of energie om twee afzonderlijke codebases te onderhouden. Ik moet de dingen eenvoudig houden.

Elk uur dat ik aan de website besteed, is een uur dat ik niet aan een van mijn producten besteed.

Ik wil dat ze op elkaar lijken, zodat gebruikers ze herkennen als onderdeel van dezelfde familie.

Ik ben geen designer. Nadat ik die look en stijl had bereikt, was ik tevreden en wilde ik niet opnieuw beginnen.

Met andere woorden: omdat het goedkoop en eenvoudig is. Het heeft me enorm veel tijd en energie bespaard, die ik aan mijn eigen product kon besteden.

Als nadeel kunnen de 2 sites de schakelaar voor lichte/donkere modus niet ondersteunen, dus hun stijl ligt vast β€” maar dat is iets waar ik mee kan leven.


OkΓ© dan! Laten we de handen uit de mouwen steken en kijken hoe het gedaan is.

Stack: De applicatie is gebaseerd op Next.js en gebruikt Tailwind CSS voor styling.

Ze is gemaakt als een combinatie van meerdere templates van Cruip, aangepast aan onze behoeften. (Die templates zijn prachtig!)

Content wordt beheerd via Contentlayer.

Extraheer de gemeenschappelijke code naar een gedeeld pakket en host alles in een monorepo

Omdat de codebase voor beide websites hetzelfde is, is het logisch om ze samen in een monorepo te hosten.

Mijn repo had oorspronkelijk een enkel project:

  • gatographql.com

Het werd geherstructureerd naar het volgende:

  • apps/gatographql.com: Gato GraphQL-website
  • apps/gatoplugins.com: Gato Plugins-website
  • packages/shared/gatoapp: Gedeelde code voor beide websites

Dit is mijn werkruimte in VSCode:

Mijn monorepo-structuur
Mijn monorepo-structuur

Ik gebruik niets geavanceerds voor een monorepo β€” een eenvoudige workspaces doet de klus prima.

Mijn package.json in de root van de monorepo ziet er nu zo uit:

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

Daarnaast heb ik scripts toegevoegd aan package.json om beide projecten te draaien/bouwen/deployen (inclusief deployen naar Netlify, waar ze allebei gehost worden):

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

Converteer componenten om props te accepteren voor aangepaste data

Zoveel mogelijk verplaatsen we code van elk van de websites naar het gedeelde pakket, en daarna passen we het gedrag aan via props.

Zo bevat het gedeelde pakket gatoapp een BlogSection-component (om de /blog-pagina op beide sites te tonen):

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

Alle inhoud is hetzelfde, behalve:

  • De paginaheader (titel/beschrijving)
  • De blogberichten
  • De campagnebanner

Omdat de twee websites onafhankelijk van elkaar eigen campagnes kunnen voeren, legt het doorgeven van campaignBanner als React.ReactNode geen beperkingen op aan het aanpassen van campagnes.

Zo voer ik op het moment dat ik dit blogartikel publiceer een campagne in Gato GraphQL, maar niet in Gato Plugins:

Campagnebanner op gatographql.com
Campagnebanner op gatographql.com

Om de blogberichten te injecteren is er wat meer logica nodig.

Blogberichten injecteren

De data voor de blogberichten wordt via de blogPosts-prop aan BlogSection doorgegeven.

Omdat ik Contentlayer gebruik, heeft elke website een contentlayer.config.js-bestand in de root, dat de types op de site definieert.

Dit configuratiebestand kan niet naar het gedeelde gatoapp worden verplaatst. Daarom maken we een exportmodule om de configuratie voor de gedeelde types te leveren, en importeren we deze vervolgens in de contentlayer.config.js van elke site, waardoor de logica DRY blijft.

gatoapp heeft een exportmodule contentlayer.config.js die het gedeelde type BlogPost levert:

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

Het bestand contentlayer.config.js in zowel apps/gatographql.com als apps/gatoplugins.com kan dat type vervolgens importeren:

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

Normaal gesproken zou je, om naar het type BlogPost in je code te verwijzen, het zo importeren:

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

Het type BlogPost leeft echter onder de website, niet onder het gedeelde pakket, dus de gedeelde code kan niet rechtstreeks naar dat type verwijzen.

We lossen dit op met een hack: we kopiΓ«ren de definitie van dat type uit het gecompileerde Contentlayer-bestand (onder apps/gatographql/.contentlayer/generated/types.d.ts) en plakken het in een nieuw types.tsx-bestand in het gedeelde pakket:

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

Vervolgens verwijzen we naar dit gedeelde type in de gedeelde code:

import { BlogPost } from 'gatoapp/types'

Omdat de properties tussen de BlogPost-types in de website en het gedeelde pakket hetzelfde zijn, kunnen we de eerste doorgeven aan een component dat de laatste verwacht.

Maak een context om globale props te injecteren

Navigatiemenucomponenten worden weergegeven in de gedeelde code, maar ze moeten worden aangeleverd via de websitecode, want elke website heeft zijn eigen menu's.

De menu's verschijnen op alle pagina's en we willen ze niet steeds opnieuw via props doorgeven. Daarom gebruiken we een React context, waarmee we de navigatiemenucomponenten slechts eenmaal kunnen injecteren.

We maken een context genaamd AppComponent in het gedeelde pakket:

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

We verwijzen ernaar in ons gedeelde pakket:

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

En we injecteren het via de websitecode, in 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>
  )
}

Tot slot implementeert de website zijn eigen HeaderMenu-component:

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

Stijlen voor lichte en donkere modus

In Tailwind zetten we een klasse vooraf met dark: om die te gebruiken wanneer de donkere modus is ingeschakeld.

Onze gedeelde pakketcode moet dus stijlen bevatten voor zowel de lichte als de donkere variant.

Zo toont de component PageHeader de beschrijving met verschillende kleuren voor de lichte modus (text-gray-600) en de donkere modus (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>
  )
}

Stel de lichte of donkere modus in op de site

gatographql.com gebruikt de donkere modus. Dit wordt gedefinieerd door de klassenaam dark toe te voegen aan <body> in het bestand apps/gatographql/app/layout.tsx (plus klassenamen voor 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 gebruikt de lichte modus. Dit is de standaardmodus, dus er hoeft geen specifieke klassenaam aan <body> te worden toegevoegd (alleen die voor 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>
  )
}

Dat is alles

Ik heb nu 2 websites, die ik voor de prijs van 1 heb gekregen. En ik ben daar erg blij mee.

Ga nu de 7 verschillen zoeken en win je prijs! πŸ˜…


Ontdek wat er komen gaat

Schrijf je in voor onze nieuwsbrief: verneem wanneer we een nieuwe versie uitbrengen, een nieuwe plugin lanceren of nieuws met je te delen hebben.