Blog

👭 2 Next.js-Websites zum Preis von 1 bauen, durch Hacking des Hell/Dunkel-Modus

Leonardo Losoviz
Von Leonardo Losoviz ·

Kürzlich hat das Team von Gato GraphQL Gato Plugins gestartet, eine Schwester-Website von Gato GraphQL.

Du wirst feststellen, dass beide Websites identisch sind! Der einzige Unterschied zwischen den beiden ist das Farbschema: Gato GraphQL verwendet ein dunkles Theme, während Gato Plugins ein helles Theme nutzt.

Der Blog-Bereich auf beiden Websites ist exakt gleich:

Blog-Bereich auf gatographql.com
Blog-Bereich auf gatographql.com
Blog-Bereich auf gatoplugins.com
Blog-Bereich auf gatoplugins.com

Auch der Docs-Bereich ist gleich:

Docs-Bereich auf gatographql.com
Docs-Bereich auf gatographql.com
Docs-Bereich auf gatoplugins.com
Docs-Bereich auf gatoplugins.com

Manchmal unterscheidet sich ein Bereich, aber das zugrundeliegende Fundament ist dasselbe.

Zum Beispiel verwenden die Extensions von Gato GraphQL und die Plugins von Gato Plugins dasselbe Layout:

Extensions-Bereich auf gatographql.com
Extensions-Bereich auf gatographql.com
Plugins-Bereich auf gatoplugins.com
Plugins-Bereich auf gatoplugins.com

(Übrigens sind auch die Logos praktisch identisch! 😜)

Logo auf gatographql.com
Logo auf gatographql.com
Logo auf gatoplugins.com
Logo auf gatoplugins.com

Und ja, dieser Blogbeitrag ist auch auf beiden Websites! 😂

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

Es gibt jedoch genau 7 Unterschiede zwischen den Beiträgen auf den beiden Websites. Kannst du sie alle finden? Wenn ja, schenke ich dir einen Gutschein mit Rabatt für Gato GraphQL 🙏

Warum wir den Hell/Dunkel-Modus genutzt haben, um 2 Websites zu erstellen

Dafür gibt es mehrere Gründe:

Ich habe weder die Zeit noch die Energie, zwei separate Codebasen zu pflegen. Ich muss die Dinge einfach halten.

Jede Stunde, die ich an der Website verbringe, ist eine Stunde, die ich nicht in eines meiner Produkte investiere.

Ich möchte, dass sie ähnlich aussehen, damit Nutzer sie als Teil derselben Familie erkennen.

Ich bin kein Designer. Nachdem ich dieses Aussehen und diesen Stil erreicht hatte, war ich zufrieden und wollte nicht von vorne anfangen.

Mit anderen Worten: weil es günstig und einfach ist. Es hat mir jede Menge Zeit und Energie gespart, die ich in mein eigenes Produkt investieren konnte.

Als Nachteil können die 2 Websites keinen Hell/Dunkel-Modus-Umschalter anbieten, ihr Stil ist also festgelegt – aber damit kann ich gut leben.


Gut dann! Lass uns die Ärmel hochkrempeln und sehen, wie es gemacht wurde.

Stack: Die Anwendung basiert auf Next.js und verwendet Tailwind CSS für das Styling.

Sie wurde als Kombination mehrerer Templates von Cruip erstellt, angepasst an unsere Bedürfnisse. (Diese Templates sind wunderschön!)

Inhalte werden über Contentlayer verwaltet.

Den gemeinsamen Code in ein geteiltes Paket auslagern und alles in einem Monorepo hosten

Da die Codebasis für beide Websites dieselbe ist, macht es nur Sinn, alles gemeinsam in einem Monorepo zu hosten.

Mein Repository enthielt ursprünglich ein einziges Projekt:

  • gatographql.com

Es wurde wie folgt umstrukturiert:

  • apps/gatographql.com: Gato GraphQL-Website
  • apps/gatoplugins.com: Gato Plugins-Website
  • packages/shared/gatoapp: Gemeinsamer Code für beide Websites

Das ist mein Arbeitsbereich in VSCode:

Meine Monorepo-Struktur
Meine Monorepo-Struktur

Ich verwende nichts Ausgefallenes für ein Monorepo – einfache workspaces erledigen die Arbeit gut.

Meine package.json im Stammverzeichnis des Monorepos sieht nun so aus:

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

Außerdem habe ich der package.json Skripte hinzugefügt, um beide Projekte auszuführen/zu bauen/zu deployen (einschließlich des Deployments auf Netlify, wo beide gehostet werden):

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

Komponenten so umwandeln, dass sie Props für benutzerdefinierte Daten empfangen

So weit wie möglich verschieben wir Code von den einzelnen Websites in das geteilte Paket und passen das Verhalten dann über Props an.

Zum Beispiel enthält das geteilte Paket gatoapp eine BlogSection-Komponente (um die /blog-Seite auf beiden Websites anzuzeigen):

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 Inhalte sind gleich, außer:

  • Der Seitenheader (Titel/Beschreibung)
  • Die Blogbeiträge
  • Das Kampagnenbanner

Da die beiden Websites unabhängig voneinander ihre eigenen Kampagnen betreiben können, schränkt die Übergabe von campaignBanner als React.ReactNode die Anpassung der Kampagnen in keiner Weise ein.

Zum Beispiel führe ich, während ich diesen Blogbeitrag veröffentliche, eine Kampagne auf Gato GraphQL durch, aber nicht auf Gato Plugins:

Kampagnenbanner auf gatographql.com
Kampagnenbanner auf gatographql.com

Für das Einschleusen der Blogbeiträge braucht es etwas mehr Logik.

Blogbeiträge einschleusen

Die Daten für die Blogbeiträge werden über die blogPosts-Prop in BlogSection übergeben.

Da ich Contentlayer verwende, hat jede Website eine contentlayer.config.js-Datei im Stammverzeichnis, die die Typen der Website definiert.

Diese Konfigurationsdatei kann nicht in das geteilte gatoapp verschoben werden. Daher erstellen wir ein Export-Modul, das die Konfiguration für die gemeinsamen Typen bereitstellt, und importieren diese dann in die contentlayer.config.js jeder Website – damit bleibt die Logik DRY.

gatoapp hat ein Export-Modul contentlayer.config.js, das den gemeinsamen Typ BlogPost bereitstellt:

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

Die Datei contentlayer.config.js in apps/gatographql.com und apps/gatoplugins.com kann diesen Typ dann importieren:

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

Normalerweise würden wir den Typ BlogPost in unserem Code so importieren:

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

Der Typ BlogPost lebt jedoch unter der Website, nicht unter dem geteilten Paket – der gemeinsame Code kann diesen Typ also nicht direkt referenzieren.

Wir lösen das mit einem Trick: Wir kopieren die Definition dieses Typs aus der kompilierten Contentlayer-Datei (unter apps/gatographql/.contentlayer/generated/types.d.ts) und fügen sie in eine neue types.tsx-Datei im geteilten Paket ein:

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

Dann referenzieren wir diesen gemeinsamen Typ im gemeinsamen Code:

import { BlogPost } from 'gatoapp/types'

Da die Eigenschaften der BlogPost-Typen in der Website und im geteilten Paket identisch sind, können wir den ersteren an eine Komponente übergeben, die den letzteren erwartet.

Einen Kontext erstellen, um globale Props einzuschleusen

Navigationsmenü-Komponenten werden im gemeinsamen Code gerendert, müssen aber über den Website-Code bereitgestellt werden, da jede Website ihre eigenen Menüs hat.

Die Menüs erscheinen auf allen Seiten, und wir wollen sie nicht immer wieder über Props übergeben müssen. Daher verwenden wir einen React-Kontext, der es uns erlaubt, die Navigationsmenü-Komponenten nur einmal einzuschleusen.

Wir erstellen einen Kontext namens AppComponent im geteilten Paket:

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

Wir referenzieren ihn in unserem geteilten Paket:

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

Und wir schleusen ihn über den Website-Code ein, 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>
  )
}

Schließlich implementiert die Website ihre eigene HeaderMenu-Komponente:

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

Stile für Hell- und Dunkelmodus

In Tailwind stellt man einer Klasse dark: voran, um sie im Dunkelmodus zu verwenden.

Unser gemeinsamer Paket-Code muss daher die Stile sowohl für die helle als auch für die dunkle Variante enthalten.

Zum Beispiel rendert die Komponente PageHeader die Beschreibung mit unterschiedlichen Farben für den Hellmodus (text-gray-600) und den Dunkelmodus (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>
  )
}

Den Hell- oder Dunkelmodus auf der Website setzen

gatographql.com verwendet den Dunkelmodus. Dieser wird definiert, indem die Klasse dark dem <body> in der Datei apps/gatographql/app/layout.tsx hinzugefügt wird (plus Klassen für das 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 verwendet den Hellmodus. Das ist der Standardmodus, daher muss dem <body> keine besondere Klasse hinzugefügt werden (nur Styling-Klassen: 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>
  )
}

Das war's

Ich habe jetzt 2 Websites zum Preis von 1 bekommen. Und ich bin sehr zufrieden damit.

Jetzt, geh und finde die 7 Unterschiede und hol dir deinen Preis! 😅


Erfahre, was als Nächstes kommt

Abonniere unseren Newsletter: Erfahre, wenn wir eine neue Version veröffentlichen, ein neues Plugin starten oder Neuigkeiten mit dir teilen.