Blog

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

Leonardo Losoviz
Di Leonardo Losoviz ·

Di recente il team di Gato GraphQL ha lanciato Gato Plugins, un sito gemello di Gato GraphQL.

Noterai che sono entrambi lo stesso sito! L'unica differenza tra i due è lo schema di colori: Gato GraphQL ha un tema scuro, mentre Gato Plugins ha un tema chiaro.

La sezione blog su 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, tuttavia la base sottostante è la stessa.

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 del blog è su entrambi i siti! 😂

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

Tuttavia, ci sono esattamente 7 differenze tra gli articoli sui due siti. Riesci a trovarle tutte? Se ci riesci, ti darò 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 per mantenere due basi di codice separate. Devo mantenere le cose semplici.

Ogni ora che passo sul sito web è un'ora che non passo su uno dei miei prodotti.

Voglio che si assomiglino, così che gli utenti possano riconoscerli come parte della stessa famiglia.

Non sono un designer. Avendo raggiunto quell'aspetto e quello stile, ero soddisfatto e non volevo ripartire da zero.

In altre parole: perché è economico e facile. Mi ha fatto risparmiare un'enorme quantità di tempo ed energia, che ho potuto dedicare al mio prodotto.

Come svantaggio, i 2 siti non possono supportare il passaggio tra modalità scura/chiara, quindi il loro stile è fisso, ma è qualcosa con cui posso convivere.


Bene, allora! Mettiamoci al lavoro e vediamo come è stato fatto.

Stack: L'applicazione è basata su Next.js, e Tailwind CSS per lo stile.

È stata creata come combinazione di 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 package condiviso e ospitare tutto in un monorepo

Poiché la base di codice per entrambi i siti è la stessa, ha senso ospitarli tutti insieme in un monorepo.

Il mio repository aveva originariamente un solo progetto:

  • gatographql.com

È stato ristrutturato come segue:

  • apps/gatographql.com: Sito web di Gato GraphQL
  • apps/gatoplugins.com: Sito web di Gato Plugins
  • packages/shared/gatoapp: Codice condiviso tra i due siti

Questo è il mio spazio di lavoro in VSCode:

La struttura del mio monorepo
La struttura del mio monorepo

Non uso nulla di sofisticato per un monorepo, un semplice workspaces fa benissimo il lavoro.

Il mio package.json alla radice del monorepo ora ha questo aspetto:

{
  "name": "gatowebsites",
  "version": "3.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 sono entrambi 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 per ricevere props con dati personalizzati

Per quanto possibile, spostiamo il codice di ciascun sito nel package condiviso, e poi personalizziamo il comportamento tramite le props.

Per esempio, il package condiviso gatoapp contiene un componente BlogSection (per visualizzare 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 = "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>
  )
}

Tutti i contenuti sono gli stessi, tranne per:

  • L'intestazione di pagina (titolo/descrizione)
  • Gli articoli del blog
  • Il banner della campagna

Poiché i due siti possono condurre le proprie campagne indipendentemente l'uno dall'altro, passare campaignBanner come React.ReactNode non limita la personalizzazione delle campagne.

Per esempio, mentre pubblico questo articolo del blog, 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.

Iniezione degli articoli del blog

I dati degli articoli del blog vengono iniettati in BlogSection tramite la prop blogPosts.

Poiché 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 package condiviso gatoapp. Creiamo quindi un modulo di export per fornire la configurazione dei tipi condivisi, e poi li importiamo nel contentlayer.config.js di ciascun sito, rendendo la logica DRY.

gatoapp ha 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 fare riferimento al tipo BlogPost nel nostro codice, lo importeremmo così:

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

Tuttavia, il tipo BlogPost si trova sotto il sito, non sotto il package condiviso, quindi il codice condiviso non può fare riferimento direttamente a quel tipo.

Risolviamo questo problema con un hack: 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 nel package 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 facciamo riferimento a questo tipo condiviso nel codice condiviso:

import { BlogPost } from 'gatoapp/types'

Poiché le proprietà tra i tipi BlogPost del sito e del package 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 saranno renderizzati nel codice condiviso, ma devono essere forniti tramite il codice del sito, poiché ogni sito avrà i propri menu.

I menu appaiono 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 package 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 referenziamo nel nostro package 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>
      </Dropdown>
    </ul>
  )
}

Stili per le modalità chiara e scura

In Tailwind, anteponiamo a una classe dark: per usarla quando la modalità scura è attiva.

Quindi, il codice del nostro package condiviso deve 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 al <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. Questa è la modalità predefinita, quindi non è necessario aggiungere alcuna classe particolare al <body> (solo quelle per lo stile: 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>
  )
}

Questo è tutto

Ora ho 2 siti web, ottenuti al prezzo di 1. E ne sono molto contento.

Ora, vai a trovare le 7 differenze, e ritira il tuo premio! 😅


Iscriviti alla nostra newsletter

Resta aggiornato su tutte le novità di Gato GraphQL.