Blog

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

Leonardo Losoviz
Door Leonardo Losoviz Β·

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

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

Het 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:

Documentatiesectie op gatographql.com
Documentatiesectie op gatographql.com
Documentatiesectie op gatoplugins.com
Documentatiesectie op gatoplugins.com

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

Zo gebruiken de extensies van Gato GraphQL en de plugins van Gato Plugins bijvoorbeeld 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, deze blogpost staat ook op beide sites! πŸ˜‚

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

Er zijn echter precies 7 verschillen tussen de posts op de twee sites. Kun je ze allemaal vinden? Als je dat doet, geef ik je een coupon met korting 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 zaken 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 er vergelijkbaar uitzien, zodat gebruikers ze kunnen herkennen als onderdeel van dezelfde familie.

Ik ben geen ontwerper. Nadat ik die uitstraling en stijl had bereikt, was ik tevreden en wilde ik niet van voren af aan beginnen.

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

Als nadeel kunnen de 2 sites de donkere/lichte modusschakelaar niet ondersteunen, dus hun stijl ligt vast, maar dat is iets waar ik mee kan leven.


OkΓ© dan! Laten we onze handen vuil maken en kijken hoe het gedaan is.

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

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

Content wordt beheerd via Contentlayer.

De gemeenschappelijke code uitpakken in een gedeeld pakket en alles in een monorepo plaatsen

Aangezien de codebase voor beide websites hetzelfde is, is het logisch om ze allemaal samen in een monorepo te plaatsen.

Mijn repo had oorspronkelijk één project:

  • gatographql.com

Het werd omgestructureerd naar het volgende:

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

Dit is mijn workspace in VSCode:

Mijn monorepo-structuur
Mijn monorepo-structuur

Ik gebruik niets bijzonders voor een monorepo, een eenvoudige workspaces doet het werk goed.

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

{
  "name": "gatowebsites",
  "version": "3.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 beide 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"
  }
}

Componenten omzetten om props te ontvangen voor aangepaste data

Zoveel mogelijk verplaatsen we code van elk van de websites naar het gedeelde pakket, waarna we het gedrag aanpassen via props.

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

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

Alle content is hetzelfde, behalve:

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

Aangezien de twee websites hun eigen campagnes onafhankelijk van elkaar kunnen uitvoeren, beperkt het doorgeven van campaignBanner als React.ReactNode het aanpassen van campagnes niet.

Zo voer ik bijvoorbeeld een campagne in Gato GraphQL op het moment dat ik deze blogpost publiceer, maar niet in Gato Plugins:

Campagnebanner op gatographql.com
Campagnebanner op gatographql.com

Het injecteren van blogposts vereist wat meer logica.

Blogposts injecteren

De data voor de blogposts wordt via de blogPosts-prop geΓ―njecteerd in BlogSection.

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 worden verplaatst naar het gedeelde gatoapp. Daarom maken we een exportmodule om de configuratie voor de gedeelde types te leveren, en importeren we deze vervolgens in de contentlayer.config.js voor 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 dan 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 type BlogPost in je code als volgt importeren:

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

Echter, type BlogPost bevindt zich onder de website, niet onder het gedeelde pakket, dus de gedeelde code kan dat type niet rechtstreeks raadplegen.

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 eigenschappen tussen de BlogPost-types in de website en het gedeelde pakket hetzelfde zijn, kunnen we de eerste doorgeven aan een component die de laatste verwacht.

Een context aanmaken om globale props te injecteren

Navigatiemenu-componenten worden weergegeven in de gedeelde code, maar ze moeten worden geleverd via de websitecode, omdat elke website zijn eigen menu's heeft.

De menu's verschijnen op alle pagina's, en we willen ze niet steeds opnieuw via props hoeven door te geven. Daarom gebruiken we een React-context, waarmee we de navigatiemenu-componenten slechts eenmalig 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>
  )
}

Ten slotte 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>
      </Dropdown>
    </ul>
  )
}

Stijlen voor lichte en donkere modi

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

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

De component PageHeader geeft de beschrijving bijvoorbeeld weer in 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>
  )
}

De lichte of donkere modus instellen op de site

gatographql.com gebruikt de donkere modus. Die wordt gedefinieerd door classname dark toe te voegen aan <body> in het bestand apps/gatographql/app/layout.tsx (plus classnames 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 is geen speciale classname nodig voor <body> (alleen die voor 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>
  )
}

Dat is het

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

Nu, ga de 7 verschillen zoeken, en ontvang je prijs! πŸ˜…


Abonneer je op onze nieuwsbrief

Blijf op de hoogte van alle updates over Gato GraphQL.