Blog

👭 Costruire 2 siti Next.js al prezzo di 1, sfruttando la modalità chiara/scura

Leonardo Losoviz
Di Leonardo Losoviz ·

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:

Sezione blog su gatographql.com
Sezione blog su gatographql.com
Sezione blog su gatoplugins.com
Sezione blog su gatoplugins.com

Anche la sezione docs è la stessa:

Sezione docs su gatographql.com
Sezione docs su gatographql.com
Sezione docs su gatoplugins.com
Sezione docs su gatoplugins.com

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:

Sezione estensioni su gatographql.com
Sezione estensioni su gatographql.com
Sezione plugin su gatoplugins.com
Sezione plugin su gatoplugins.com

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

Logo su gatographql.com
Logo su gatographql.com
Logo su gatoplugins.com
Logo su gatoplugins.com

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:

La struttura del mio monorepo
La struttura del mio monorepo

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:

Banner della campagna su gatographql.com
Banner della campagna su gatographql.com

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


Scopri cosa arriva dopo

Iscriviti alla nostra newsletter: ti avviseremo quando pubblicheremo una nuova versione, lanceremo un nuovo plugin o avremo novità da condividere con te.