Blog

👭 Construire 2 sites Next.js au prix d’1, en dĂ©tournant le mode clair/sombre

Leonardo Losoviz
Par Leonardo Losoviz ·

RĂ©cemment, l’équipe de Gato GraphQL a lancĂ© Gato Plugins, un site jumeau de Gato GraphQL.

Vous remarquerez qu’il s’agit du mĂȘme site ! La seule diffĂ©rence entre les deux est la palette de couleurs : Gato GraphQL utilise un thĂšme sombre, tandis que Gato Plugins utilise un thĂšme clair.

La section blog des 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 elle aussi 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 reste 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 aussi sont quasiment identiques ! 😜)

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

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

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

Il y a cependant exactement 7 diffĂ©rences entre les articles des deux sites. Saurez-vous toutes les repĂ©rer ? Si vous y parvenez, je vous offre un coupon avec une 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 ni le temps ni l’énergie de maintenir deux bases de code distinctes. Je dois faire simple.

Chaque heure que je passe sur le site est une heure que je ne consacre pas à l’un de mes produits.

Je veux qu’ils se ressemblent, pour que les utilisateurs les reconnaissent comme faisant partie de la mĂȘme famille.

Je ne suis pas designer. Une fois ce look et ce style obtenus, j’étais satisfait et je ne voulais pas repartir de zĂ©ro.

Autrement dit : parce que c’est Ă©conomique et facile. Cela m’a fait gagner Ă©normĂ©ment de temps et d’énergie, que j’ai pu consacrer Ă  mon propre produit.

En contrepartie, les 2 sites ne peuvent pas proposer le bouton de bascule clair/sombre, donc leur style est figĂ©, mais c’est quelque chose avec quoi je peux vivre.

TrÚs bien ! Alors mettons les mains dans le cambouis et voyons comment cela a été fait.

Stack : l’application est basĂ©e sur Next.js et utilise Tailwind CSS pour la mise en forme.

Elle a Ă©tĂ© créée Ă  partir d’une combinaison de plusieurs templates de Cruip, personnalisĂ©s selon nos besoins. (Ces templates sont superbes !)

Le contenu est géré avec Contentlayer.

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

Comme la base de code des deux sites est la mĂȘme, il est logique de tout hĂ©berger ensemble dans un monorepo.

Mon dĂ©pĂŽt ne contenait Ă  l’origine qu’un seul projet :

  • gatographql.com

Il a été restructuré comme suit :

  • apps/gatographql.com : site web Gato GraphQL
  • apps/gatoplugins.com : site web 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 le monorepo ; de simples workspaces font trĂšs bien l’affaire.

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

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

J’ai en plus ajoutĂ© des scripts Ă  package.json pour lancer/compiler/dĂ©ployer les deux projets (y compris le dĂ©ploiement vers 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 qu’ils reçoivent des props pour les donnĂ©es personnalisĂ©es

Autant que possible, nous déplaçons le code de chacun des sites vers le paquet partagé, puis nous personnalisons le comportement via les props.

Par exemple, le paquet 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 = "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>
  )
}

Tout le contenu est identique, sauf :

  • L’en-tĂȘte de page (titre/description)
  • Les articles du blog
  • La banniĂšre de campagne

Comme les deux sites peuvent mener leurs propres campagnes indĂ©pendamment l’un de l’autre, passer campaignBanner en tant que React.ReactNode ne limite en rien la personnalisation des campagnes.

Par exemple, au moment oĂč je publie cet article, 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 du blog, il faut un peu plus de logique.

Injecter les articles du blog

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

Comme 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Ă© dans le paquet partagĂ© gatoapp. Nous crĂ©ons donc un module d’export qui fournit la configuration des types partagĂ©s, puis nous les importons dans le contentlayer.config.js de chaque site, ce qui rend la logique DRY.

gatoapp dispose d’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 comme dans 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 ainsi :

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

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

Nous résolvons cela avec une astuce : 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 du paquet 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'

Comme les propriétés des types BlogPost du site et du paquet partagé sont identiques, 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 affichĂ©s dans le code partagĂ©, mais ils doivent ĂȘtre fournis par 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, qui nous permet d’injecter les composants du menu de navigation une seule fois.

Nous créons un contexte appelé AppComponent dans le paquet 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 paquet 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>
        <li>
          <Link href='/roadmap'>Roadmap</Link>
        </li>
      </Dropdown>
    </ul>
  )
}

Styles pour les modes clair et sombre

Dans Tailwind, on prĂ©fixe une classe avec dark: pour l’appliquer lorsque le mode sombre est activĂ©.

Le code de notre paquet partagé doit donc contenir les styles pour les variantes claire et sombre.

Par exemple, le composant PageHeader affiche la description avec des couleurs différentes 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 à <body> dans le fichier apps/gatographql/app/layout.tsx (plus des classes de mise en forme : 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, il n’est donc pas nĂ©cessaire d’ajouter de classe particuliĂšre Ă  <body> (uniquement celles de mise en forme : 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>
  )
}

Et voilĂ 

J’ai maintenant 2 sites web, obtenus pour le prix d’1. Et j’en suis trùs content.

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


Découvrez ce qui arrive ensuite

Abonnez-vous à notre newsletter : nous vous prévenons quand nous publions une nouvelle version, lançons un nouveau plugin ou avons des nouveautés à partager avec vous.