Blog

👭 Construire 2 sites Next.js au prix d'un, en exploitant les modes sombre/clair

Leonardo Losoviz
Par Leonardo Losoviz ·

Récemment, l'équipe de Gato GraphQL a lancé Gato Plugins, un site frère de Gato GraphQL.

Vous remarquerez qu'ils sont tous les deux le même site ! La seule différence entre les deux est le schéma de couleurs : Gato GraphQL est en thème sombre, tandis que Gato Plugins est en thème clair.

La section blog sur les deux sites est exactement la même :

Section blog sur gatographql.com
Section blog sur gatographql.com
Section blog sur gatoplugins.com
Section blog sur gatoplugins.com

La section docs est également la même :

Section docs sur gatographql.com
Section docs sur gatographql.com
Section docs sur gatoplugins.com
Section docs sur gatoplugins.com

Parfois la section est différente, mais la base sous-jacente est la même.

Par exemple, les extensions de Gato GraphQL et les plugins de Gato Plugins utilisent la même mise en page :

Section extensions sur gatographql.com
Section extensions sur gatographql.com
Section plugins sur gatoplugins.com
Section plugins sur gatoplugins.com

(Au fait, les logos sont aussi pratiquement identiques ! 😜)

Logo sur gatographql.com
Logo sur gatographql.com
Logo sur gatoplugins.com
Logo sur gatoplugins.com

Et oui, cet article de blog est aussi sur les deux sites ! 😂

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

Cependant, il y a exactement 7 différences entre les articles des deux sites. Pouvez-vous toutes les trouver ? Si oui, je vous donnerai un coupon de réduction pour Gato GraphQL 🙏

Pourquoi nous avons utilisé les modes clair/sombre pour produire 2 sites web

Il y a plusieurs raisons :

Je n'ai pas le temps ni l'énergie de maintenir deux bases de code séparées. J'ai besoin de garder les choses simples.

Chaque heure que je passe sur le site web est une heure que je ne passe pas sur l'un de mes produits.

Je veux qu'ils se ressemblent, afin que les utilisateurs puissent les reconnaître comme faisant partie de la même famille.

Je ne suis pas designer. Ayant atteint cet aspect et ce style, j'étais satisfait, et je ne voulais pas repartir de zéro.

En d'autres termes : parce que c'est bon marché et facile. Cela m'a économisé un temps et une énergie considérables, que j'ai pu consacrer à mon propre produit.

Comme inconvénient, les 2 sites ne peuvent pas prendre en charge la bascule entre les modes clair/sombre, donc leur style est fixe, mais c'est quelque chose avec lequel je peux vivre.


Très bien ! Mettons-nous au travail, et voyons comment c'est fait.

Stack : L'application est basée sur Next.js, et Tailwind CSS pour le style.

Elle a été créée comme une combinaison de plusieurs modèles de Cruip, personnalisés selon nos besoins. (Ces modèles sont magnifiques !)

Le contenu est géré via Contentlayer.

Extraire le code commun dans un package partagé, et tout héberger dans un monorepo

Puisque la base de code pour les deux sites est la même, il est logique de les héberger tous ensemble dans un monorepo.

Mon dépôt avait à l'origine un seul projet :

  • gatographql.com

Il a été restructuré comme suit :

  • apps/gatographql.com : Site web de Gato GraphQL
  • apps/gatoplugins.com : Site web de Gato Plugins
  • packages/shared/gatoapp : Code partagé entre les deux sites

Voici mon espace de travail dans VSCode :

La structure de mon monorepo
La structure de mon monorepo

Je n'utilise rien de sophistiqué pour un monorepo, un simple workspaces fait très bien le travail.

Mon package.json à la racine du monorepo ressemble maintenant à ceci :

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

De plus, j'ai ajouté des scripts à package.json pour exécuter/construire/déployer les deux projets (y compris le déploiement sur Netlify, où ils sont tous les deux hébergés) :

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

Convertir les composants pour recevoir des props avec des données personnalisées

Dans la mesure du possible, nous déplaçons le code de chacun des sites dans le package partagé, puis nous personnalisons le comportement via des props.

Par exemple, le package partagé gatoapp contient un composant BlogSection (pour afficher la page /blog sur les deux sites) :

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

Tout le contenu est le même, sauf pour :

  • L'en-tête de page (titre/description)
  • Les articles de blog
  • La bannière de campagne

Puisque les deux sites peuvent mener leurs propres campagnes indépendamment l'un de l'autre, passer campaignBanner comme React.ReactNode ne limite pas la personnalisation des campagnes.

Par exemple, au moment où je publie cet article de blog, je mène une campagne sur Gato GraphQL, mais pas sur Gato Plugins :

Bannière de campagne sur gatographql.com
Bannière de campagne sur gatographql.com

Pour injecter les articles de blog, il faut un peu plus de logique.

Injection des articles de blog

Les données des articles de blog sont injectées dans BlogSection via la prop blogPosts.

Puisque j'utilise Contentlayer, chaque site aura un fichier contentlayer.config.js à la racine, définissant les types du site.

Ce fichier de configuration ne peut pas être déplacé vers le package partagé gatoapp. Nous créons donc un module d'export pour fournir la configuration des types partagés, puis nous les importons dans le contentlayer.config.js de chaque site, rendant la logique DRY.

gatoapp a un module d'export contentlayer.config.js fournissant le type partagé BlogPost :

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

Le fichier contentlayer.config.js dans apps/gatographql.com et apps/gatoplugins.com peut alors importer ce type :

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

Normalement, pour référencer le type BlogPost dans notre code, nous l'importerions comme ceci :

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

Cependant, le type BlogPost se trouve sous le site, pas sous le package partagé, donc le code partagé ne peut pas référencer directement ce type.

Nous résolvons cela avec un hack : nous copions la définition de ce type depuis le fichier Contentlayer compilé (sous apps/gatographql/.contentlayer/generated/types.d.ts), et nous la collons dans un nouveau fichier types.tsx dans le package partagé :

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

Nous référençons ensuite ce type partagé dans le code partagé :

import { BlogPost } from 'gatoapp/types'

Puisque les propriétés entre les types BlogPost du site et du package partagé sont les mêmes, nous pouvons passer le premier à un composant qui attend le second.

Créer un contexte pour injecter des props globales

Les composants du menu de navigation seront rendus dans le code partagé, mais ils doivent être fournis via le code du site, car chaque site aura ses propres menus.

Les menus apparaissent sur toutes les pages, et nous ne voulons pas avoir à les passer via des props à chaque fois. Nous utilisons donc un contexte React, nous permettant d'injecter les composants du menu de navigation une seule fois.

Nous créons un contexte appelé AppComponent dans le package partagé :

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

Nous le référençons dans notre package partagé :

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

Et nous l'injectons via le code du site, dans 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>
  )
}

Enfin, le site implémente son propre composant HeaderMenu :

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

Styles pour les modes clair et sombre

Dans Tailwind, nous préfixons une classe avec dark: pour l'utiliser lorsque le mode sombre est activé.

Ainsi, le code de notre package partagé doit contenir les styles pour les variantes claires et sombres.

Par exemple, le composant PageHeader affiche la description avec différentes couleurs pour le mode clair (text-gray-600) et le mode sombre (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>
  )
}

Définir le mode clair ou sombre sur le site

gatographql.com utilise le mode sombre. Il le définit en ajoutant la classe dark au <body> dans le fichier apps/gatographql/app/layout.tsx (plus les classes de style : 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 utilise le mode clair. C'est le mode par défaut, donc il n'est pas nécessaire d'ajouter une classe particulière au <body> (seulement celles pour le style : 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>
  )
}

C'est tout

J'ai maintenant 2 sites web, que j'ai obtenus pour le prix d'un. Et j'en suis très content.

Maintenant, allez trouver les 7 différences, et récupérez votre prix ! 😅


Abonnez-vous à notre newsletter

Restez au courant de toutes les nouveautés de Gato GraphQL.