👭 Costruire 2 siti Next.js al prezzo di 1, sfruttando la modalità chiara/scura
Di recente il team di Gato GraphQL ha lanciato Gato Plugins, un sito gemello di Gato GraphQL.
Noterai che si tratta dello stesso sito! L'unica differenza tra i due è la combinazione di colori: Gato GraphQL usa un tema scuro, mentre Gato Plugins usa un tema chiaro.
La sezione blog di entrambi i siti è esattamente la stessa:


Anche la sezione docs è la stessa:


A volte la sezione è diversa, ma le fondamenta sottostanti restano le stesse.
Per esempio, le estensioni di Gato GraphQL e i plugin di Gato Plugins usano lo stesso layout:


(A proposito, anche i loghi sono praticamente identici! 😜)


E sì, anche questo articolo è su entrambi i siti! 😂
Da leggere su gatographql.com: Building 2 Nextjs websites at the price of 1, by hacking the dark/light mode.
Ci sono però esattamente 7 differenze tra gli articoli dei due siti. Riuscirai a trovarle tutte? Se ci riesci, ti regalo un coupon con uno sconto per Gato GraphQL 🙏
Perché abbiamo usato le modalità chiara/scura per produrre 2 siti web
Ci sono diversi motivi:
Non ho il tempo né l'energia di mantenere due basi di codice separate. Devo tenere le cose semplici.
Ogni ora che dedico al sito è un'ora che non dedico a uno dei miei prodotti.
Voglio che si assomiglino, così che gli utenti li riconoscano come parte della stessa famiglia.
Non sono un designer. Una volta ottenuti quel look e quello stile, ero soddisfatto e non volevo ripartire da zero.
In altre parole: perché è economico e facile. Mi ha fatto risparmiare un sacco di tempo ed energia, che ho potuto dedicare al mio prodotto.
Come svantaggio, i 2 siti non possono offrire il pulsante per passare dalla modalità chiara a quella scura, quindi il loro stile è fisso, ma è una cosa con cui posso convivere.
Benissimo! Allora mettiamo le mani in pasta e vediamo come è stato fatto.
Stack: l'applicazione è basata su Next.js e usa Tailwind CSS per lo stile.
È stata creata combinando diversi template di Cruip, personalizzati secondo le nostre esigenze. (Questi template sono bellissimi!)
I contenuti sono gestiti tramite Contentlayer.
Estrarre il codice comune in un pacchetto condiviso e ospitare tutto in un monorepo
Dato che la base di codice di entrambi i siti è la stessa, ha senso ospitarli tutti insieme in un monorepo.
Il mio repository originariamente conteneva un solo progetto:
- gatographql.com
È stato ristrutturato come segue:
- apps/gatographql.com: sito web Gato GraphQL
- apps/gatoplugins.com: sito web Gato Plugins
- packages/shared/gatoapp: codice condiviso tra i due siti
Ecco il mio spazio di lavoro in VSCode:

Non uso nulla di sofisticato per il monorepo; dei semplici workspaces svolgono benissimo il compito.
Il mio package.json alla radice del monorepo ora si presenta così:
{
"name": "gatowebsites",
"version": "2.0.0",
"private": true,
"workspaces": [
"apps/*",
"packages/shared/*"
]
}Inoltre, ho aggiunto degli script a package.json per eseguire/compilare/distribuire entrambi i progetti (incluso il deploy su Netlify, dove entrambi sono ospitati):
{
"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"
}
}Convertire i componenti affinché ricevano props per i dati personalizzati
Per quanto possibile, spostiamo il codice di ciascuno dei siti nel pacchetto condiviso e poi personalizziamo il comportamento tramite le props.
Per esempio, il pacchetto condiviso gatoapp contiene un componente BlogSection (per mostrare la pagina /blog su entrambi i siti):
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>
)
}Tutto il contenuto è identico, tranne:
- L'intestazione della pagina (titolo/descrizione)
- Gli articoli del blog
- Il banner della campagna
Dato che i due siti possono condurre le proprie campagne indipendentemente l'uno dall'altro, passare campaignBanner come React.ReactNode non limita in alcun modo la personalizzazione delle campagne.
Per esempio, nel momento in cui pubblico questo articolo, sto conducendo una campagna su Gato GraphQL, ma non su Gato Plugins:

Per iniettare gli articoli del blog serve un po' più di logica.
Iniettare gli articoli del blog
I dati degli articoli del blog vengono iniettati in BlogSection tramite la prop blogPosts.
Dato che uso Contentlayer, ogni sito avrà un file contentlayer.config.js alla radice, che definisce i tipi del sito.
Questo file di configurazione non può essere spostato nel pacchetto condiviso gatoapp. Creiamo quindi un modulo di export che fornisce la configurazione dei tipi condivisi, per poi importarli nel contentlayer.config.js di ciascun sito, rendendo la logica DRY.
gatoapp dispone di un modulo di export contentlayer.config.js che fornisce il tipo condiviso 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,
},
}Il file contentlayer.config.js sia in apps/gatographql.com che in apps/gatoplugins.com può quindi importare quel tipo:
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],
})Normalmente, per riferirci al tipo BlogPost nel nostro codice, lo importeremmo così:
import { BlogPost } from '@/.contentlayer/generated'Tuttavia, il tipo BlogPost vive sotto il sito, non sotto il pacchetto condiviso, quindi il codice condiviso non può riferirsi direttamente a quel tipo.
Risolviamo la cosa con un trucco: copiamo la definizione di quel tipo dal file Contentlayer compilato (sotto apps/gatographql/.contentlayer/generated/types.d.ts) e la incolliamo in un nuovo file types.tsx del pacchetto condiviso:
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,
}Poi riferiamo questo tipo condiviso nel codice condiviso:
import { BlogPost } from 'gatoapp/types'Dato che le proprietà tra i tipi BlogPost del sito e del pacchetto condiviso sono le stesse, possiamo passare il primo a un componente che si aspetta il secondo.
Creare un contesto per iniettare props globali
I componenti del menu di navigazione verranno mostrati nel codice condiviso, ma devono essere forniti dal codice del sito, poiché ogni sito avrà i propri menu.
I menu compaiono in tutte le pagine e non vogliamo doverli passare tramite props ogni volta. Usiamo quindi un contesto React, che ci permette di iniettare i componenti del menu di navigazione una sola volta.
Creiamo un contesto chiamato AppComponent nel pacchetto condiviso:
'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)Lo riferiamo nel nostro pacchetto condiviso:
'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>
)
}E lo iniettiamo tramite il codice del sito, 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>
)
}Infine, il sito implementa il proprio componente 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>
)
}Stili per le modalità chiara e scura
In Tailwind, si antepone una classe con dark: per applicarla quando la modalità scura è attiva.
Il codice del nostro pacchetto condiviso deve quindi contenere gli stili sia per la variante chiara che per quella scura.
Per esempio, il componente PageHeader mostra la descrizione con colori diversi per la modalità chiara (text-gray-600) e la modalità scura (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>
)
}Impostare la modalità chiara o scura sul sito
gatographql.com usa la modalità scura. La definisce aggiungendo la classe dark a <body> nel file apps/gatographql/app/layout.tsx (più le classi per lo stile: 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 usa la modalità chiara. È la modalità predefinita, quindi non è necessario aggiungere alcuna classe particolare a <body> (solo quelle per lo stile: 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>
)
}Ecco fatto
Ora ho 2 siti web, ottenuti al prezzo di 1. E ne sono molto contento.
Adesso, vai a trovare le 7 differenze e ritira il tuo premio! 😅