Blog

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

Leonardo Losoviz
Von Leonardo Losoviz ·

Kürzlich hat das Gato GraphQL-Team Gato Plugins gelauncht, eine Geschwister-Website von Gato GraphQL.

Du wirst bemerken, dass beide die gleiche Website sind! Der einzige Unterschied zwischen den beiden ist das Farbschema: Gato GraphQL verwendet ein dunkles Theme, während Gato Plugins ein helles Theme hat.

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

Der Docs-Bereich ist ebenfalls gleich:

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

Manchmal ist der Bereich unterschiedlich, aber die zugrundeliegende Basis ist dieselbe.

Zum Beispiel verwenden die Erweiterungen von Gato GraphQL und die Plugins von Gato Plugins das gleiche Layout:

Erweiterungen-Bereich auf gatographql.com
Erweiterungen-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, auch dieser Blogbeitrag ist auf beiden Websites zu finden! 😂

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

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

Warum wir den Light/Dark-Modus genutzt haben, um 2 Websites zu erstellen

Es gibt 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 auf 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 beginnen.

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

Als Nachteil können die 2 Websites den Dark/Light-Modus-Umschalter nicht unterstützen, ihr Stil ist also fest – aber damit kann ich gut leben.


Also gut! Dann lass uns die Ärmel hochkrempeln und sehen, wie es gemacht wurde.

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

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

Inhalte werden über Contentlayer verwaltet.

Den gemeinsamen Code in ein geteiltes Package extrahieren und alles in einem Monorepo hosten

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

Mein Repository hatte 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 Workspace in VSCode:

Meine Monorepo-Struktur
Meine Monorepo-Struktur

Ich verwende nichts Ausgefallenes für ein Monorepo – ein einfaches workspaces erledigt die Arbeit gut.

Meine package.json im Root des Monorepos sieht jetzt so aus:

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

Außerdem habe ich Skripte zu package.json hinzugefügt, um beide Projekte zu starten/bauen/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 umbauen, dass sie Props für benutzerdefinierte Daten erhalten

So weit wie möglich verlagern wir Code von jeder der Websites in das geteilte Package und passen das Verhalten dann über Props an.

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

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

  • Der Seitenheader (Titel/Beschreibung)
  • Die Blogbeiträge
  • Das Kampagnen-Banner

Da die beiden Websites ihre eigenen Kampagnen unabhängig voneinander durchführen können, schränkt das Übergeben von campaignBanner als React.ReactNode die Anpassung der Kampagnen nicht ein.

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

Kampagnen-Banner auf gatographql.com
Kampagnen-Banner auf gatographql.com

Um die Blogbeiträge einzuspeisen, braucht es etwas mehr Logik.

Blogbeiträge einspeisen

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

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

Diese Konfigurationsdatei kann nicht in das geteilte gatoapp verschoben werden. Wir erstellen daher ein Export-Modul, das die Konfiguration für die geteilten Typen bereitstellt, und importieren diese dann in das contentlayer.config.js jeder Website, wodurch die Logik DRY wird.

gatoapp hat ein Export-Modul contentlayer.config.js, das den geteilten 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 sowohl in apps/gatographql.com als auch in 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 befindet sich jedoch unter der Website, nicht unter dem geteilten Package, sodass der geteilte Code nicht direkt auf diesen Typ verweisen kann.

Wir lösen das mit einem Hack: 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 Package 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 verweisen wir im geteilten Code auf diesen geteilten Typ:

import { BlogPost } from 'gatoapp/types'

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

Einen Kontext erstellen, um globale Props einzuspeisen

Navigationsmenü-Komponenten werden im geteilten 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 möchten sie nicht immer wieder über Props übergeben. Deshalb verwenden wir einen React-Kontext, der es uns ermöglicht, die Navigationsmenü-Komponenten nur einmal einzuspeisen.

Wir erstellen einen Kontext namens AppComponent im geteilten Package:

'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 Package:

'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 speisen 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>
      </Dropdown>
    </ul>
  )
}

Stile für den Light- und Dark-Modus

In Tailwind stellen wir einer Klasse dark: voran, um sie zu verwenden, wenn der Dark-Modus aktiviert ist.

Unser geteiltes Package-Code muss daher die Stile für beide Varianten – hell und dunkel – enthalten.

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

Den Light- oder Dark-Modus auf der Website festlegen

gatographql.com verwendet den Dark-Modus. Er wird definiert, indem dem <body> in der Datei apps/gatographql/app/layout.tsx der Klassenname dark hinzugefügt wird (plus Klassennamen 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 Light-Modus. Dies ist der Standardmodus, daher muss dem <body> kein besonderer Klassenname hinzugefügt werden (nur die Styling-Klassen: 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>
  )
}

Das war's

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

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


Abonniere unseren Newsletter

Bleib über alle Updates zu Gato GraphQL auf dem Laufenden.