đ Construire 2 sites Next.js au prix dâ1, en dĂ©tournant le mode clair/sombre
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 :


La section docs est elle aussi la mĂȘme :


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 :


(Au fait, les logos aussi sont quasiment identiques ! đ)


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 :

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 :

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