π 2 Next.js-websites bouwen voor de prijs van 1, door de donkere/lichte modus te hacken
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:


De documentatiesectie is ook hetzelfde:


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:


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


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:

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:

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