Blog

👭 Construindo 2 sites Next.js pelo preço de 1, usando o modo claro/escuro

Leonardo Losoviz
Por Leonardo Losoviz ·

Recentemente a equipe do Gato GraphQL lançou o Gato Plugins, um site irmão do Gato GraphQL.

Você vai notar que os dois são o mesmo site! A única diferença entre eles é o esquema de cores: o Gato GraphQL tem tema escuro, enquanto o Gato Plugins tem tema claro.

A seção de blog dos dois sites é exatamente a mesma:

Seção de blog em gatographql.com
Seção de blog em gatographql.com
Seção de blog em gatoplugins.com
Seção de blog em gatoplugins.com

A seção de docs também é a mesma:

Seção de docs em gatographql.com
Seção de docs em gatographql.com
Seção de docs em gatoplugins.com
Seção de docs em gatoplugins.com

Às vezes a seção é diferente, porém a base subjacente é a mesma.

Por exemplo, as extensões do Gato GraphQL e os plugins do Gato Plugins usam o mesmo layout:

Seção de extensões em gatographql.com
Seção de extensões em gatographql.com
Seção de plugins em gatoplugins.com
Seção de plugins em gatoplugins.com

(A propósito, os logos também são praticamente iguais! 😜)

Logo em gatographql.com
Logo em gatographql.com
Logo em gatoplugins.com
Logo em gatoplugins.com

E sim, este artigo também está nos dois sites! 😂

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

No entanto, há exatamente 7 diferenças entre os artigos nos dois sites. Você consegue encontrá-las todas? Se conseguir, vou te dar um cupom com desconto para o Gato GraphQL 🙏

Por que usamos os modos claro/escuro para produzir 2 sites

Há vários motivos:

Não tenho tempo nem energia para manter duas bases de código separadas. Preciso manter as coisas simples.

Cada hora que dedico ao site é uma hora que não dedico a nenhum dos meus produtos.

Quero que eles se pareçam, para que os usuários os reconheçam como parte da mesma família.

Não sou designer. Tendo alcançado aquele visual e estilo, fiquei satisfeito e não queria começar do zero.

Em outras palavras: porque é barato e fácil. Me poupou toneladas de tempo e energia, que pude empregar no meu próprio produto.

Como desvantagem, os 2 sites não suportam o botão para alternar entre os modos claro/escuro, então o estilo deles é fixo, mas é algo com que consigo conviver.


Muito bem! Então vamos arregaçar as mangas e ver como foi feito.

Stack: A aplicação é baseada em Next.js e usa Tailwind CSS para estilização.

Foi criada como uma combinação de vários templates da Cruip, personalizados para nossas necessidades. (Esses templates são lindos!)

O conteúdo é gerenciado via Contentlayer.

Extrair o código comum em um pacote compartilhado e hospedar tudo em um monorepo

Como a base de código dos dois sites é a mesma, faz sentido hospedá-los juntos em um monorepo.

Meu repositório originalmente tinha um único projeto:

  • gatographql.com

Foi reestruturado da seguinte forma:

  • apps/gatographql.com: Site do Gato GraphQL
  • apps/gatoplugins.com: Site do Gato Plugins
  • packages/shared/gatoapp: Código compartilhado entre os dois sites

Este é meu espaço de trabalho no VSCode:

Minha estrutura de monorepo
Minha estrutura de monorepo

Não uso nada sofisticado para o monorepo; um simples workspaces faz o trabalho muito bem.

Meu package.json na raiz do monorepo agora fica assim:

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

Além disso, adicionei scripts ao package.json para executar/compilar/implantar ambos os projetos (incluindo o deploy para o Netlify, onde os dois são hospedados):

{
  "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"
  }
}

Converter componentes para receber props com dados personalizados

Na medida do possível, movemos o código de cada um dos sites para o pacote compartilhado e depois personalizamos o comportamento via props.

Por exemplo, o pacote compartilhado gatoapp contém um componente BlogSection (para exibir a página /blog em ambos os 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>
  )
}

Todo o conteúdo é igual, exceto por:

  • O cabeçalho da página (título/descrição)
  • Os artigos do blog
  • O banner de campanha

Como os dois sites podem executar suas próprias campanhas de forma independente, passar campaignBanner como React.ReactNode não limita a personalização das campanhas.

Por exemplo, no momento em que publico este artigo, estou executando uma campanha no Gato GraphQL, mas não no Gato Plugins:

Banner de campanha em gatographql.com
Banner de campanha em gatographql.com

Para injetar os artigos do blog, é necessária um pouco mais de lógica.

Injetando artigos do blog

Os dados dos artigos do blog são injetados no BlogSection via a prop blogPosts.

Como estou usando o Contentlayer, cada site terá um arquivo contentlayer.config.js na raiz, definindo os tipos do site.

Esse arquivo de configuração não pode ser movido para o pacote compartilhado gatoapp. Então, criamos um módulo de exportação para fornecer a configuração dos tipos compartilhados e depois os importamos no contentlayer.config.js de cada site, tornando a lógica DRY.

O gatoapp tem um módulo de exportação contentlayer.config.js que fornece o tipo compartilhado 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,
  },
}

O arquivo contentlayer.config.js tanto em apps/gatographql.com quanto em apps/gatoplugins.com pode então importar esse 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, para referenciar o tipo BlogPost no nosso código, o importaríamos assim:

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

Porém, o tipo BlogPost vive dentro do site, não dentro do pacote compartilhado, então o código compartilhado não pode referenciar diretamente esse tipo.

Resolvemos isso com um truque: copiamos a definição desse tipo do arquivo Contentlayer compilado (em apps/gatographql/.contentlayer/generated/types.d.ts) e colamos em um novo arquivo types.tsx no pacote compartilhado:

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,
}

Em seguida, referenciamos esse tipo compartilhado no código compartilhado:

import { BlogPost } from 'gatoapp/types'

Como as propriedades entre os tipos BlogPost do site e do pacote compartilhado são as mesmas, podemos passar o primeiro para um componente que espera o segundo.

Criar um contexto para injetar props globais

Os componentes do menu de navegação serão exibidos no código compartilhado, mas precisam ser fornecidos pelo código do site, pois cada site terá seus próprios menus.

Os menus aparecem em todas as páginas e não queremos ter que passá-los via props repetidamente. Por isso, usamos um contexto React, que nos permite injetar os componentes do menu de navegação apenas uma vez.

Criamos um contexto chamado AppComponent no pacote compartilhado:

'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)

Referenciamos ele no nosso pacote compartilhado:

'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 o injetamos via código do site, em 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>
  )
}

Por fim, o site implementa seu próprio 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>
  )
}

Estilos para os modos claro e escuro

No Tailwind, prefixamos uma classe com dark: para usá-la quando o modo escuro estiver ativado.

Assim, o código do nosso pacote compartilhado deve conter os estilos para as variantes clara e escura.

Por exemplo, o componente PageHeader exibe a descrição com cores diferentes para o modo claro (text-gray-600) e o modo escuro (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>
  )
}

Definir o modo claro ou escuro no site

O gatographql.com usa o modo escuro. Ele o define adicionando a classe dark ao <body> no arquivo apps/gatographql/app/layout.tsx (mais as classes de estilo: 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>
  )
}

O gatoplugins.com usa o modo claro. Este é o modo padrão, então não há necessidade de adicionar nenhuma classe especial ao <body> (apenas as de estilo: 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>
  )
}

É isso

Agora tenho 2 sites, que consegui pelo preço de 1. E estou muito feliz com isso.

Agora, vá encontrar as 7 diferenças e resgate seu prêmio! 😅


Descubra o que vem a seguir

Assine nossa newsletter: saiba quando lançamos uma nova versão, um novo plugin, ou temos novidades para compartilhar com você.