👭 Construindo 2 sites Next.js pelo preço de 1, explorando o modo escuro/claro
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: Gato GraphQL tem tema escuro, enquanto Gato Plugins tem tema claro.
A seção de blog nos dois sites é exatamente a mesma:


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


À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:


(Aliás, os logos também são praticamente iguais! 😜)


E sim, este artigo do blog também está nos dois sites! 😂
Leia em gatoplugins.com: Building 2 Nextjs websites at the price of 1, by hacking the light/dark 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 criar 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 passo no site é uma hora que não passo em nenhum dos meus produtos.
Quero que eles se pareçam, para que os usuários possam reconhecê-los como parte da mesma família.
Não sou designer. Tendo alcançado aquela aparência e estilo, estava satisfeito e não queria começar do zero.
Em outras palavras: porque é barato e fácil. Economizei uma enorme quantidade de tempo e energia, que pude dedicar ao meu produto.
Como desvantagem, os 2 sites não podem suportar o alternador de modo escuro/claro, 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 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 package compartilhado e hospedar tudo em um monorepo
Como a base de código para os dois sites é a mesma, faz todo 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 workspace no VSCode:

Não uso nada sofisticado para um monorepo, um simples workspaces faz o trabalho muito bem.
Meu package.json na raiz do monorepo agora tem esta aparência:
{
"name": "gatowebsites",
"version": "3.0.0",
"private": true,
"workspaces": [
"apps/*",
"packages/shared/*"
]
}Além disso, adicionei scripts ao package.json para executar/compilar/implantar os dois projetos (incluindo o deploy no Netlify, onde ambos estã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 package compartilhado, e então personalizamos o comportamento via props.
Por exemplo, o package compartilhado gatoapp contém um componente BlogSection (para exibir a página /blog nos dois 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 = "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 é o mesmo, exceto por:
- O cabeçalho de página (título/descrição)
- Os artigos do blog
- O banner de campanha
Como os dois sites podem conduzir suas próprias campanhas de forma independente, passar campaignBanner como React.ReactNode não limita a personalização das campanhas.
Por exemplo, ao publicar este artigo do blog, estou rodando uma campanha no Gato GraphQL, mas não no Gato Plugins:

Para injetar os artigos do blog, é necessário 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 Contentlayer, cada site terá um arquivo contentlayer.config.js na raiz, definindo os tipos do site.
Este arquivo de configuração não pode ser movido para o package 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.
gatoapp tem módulo de exportação contentlayer.config.js fornecendo 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 em nosso código, o importaríamos assim:
import { BlogPost } from '@/.contentlayer/generated'Porém, o tipo BlogPost fica sob o site, não sob o package compartilhado, portanto o código compartilhado não pode referenciar diretamente esse tipo.
Resolvemos isso com um hack: 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 package 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,
}Então referenciamos esse tipo compartilhado no código compartilhado:
import { BlogPost } from 'gatoapp/types'Como as propriedades entre os tipos BlogPost do site e do package 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 renderizados 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. Então usamos um contexto React, que nos permite injetar os componentes do menu de navegação apenas uma vez.
Criamos um contexto chamado AppComponent no package 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 isso em nosso package 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>
</Dropdown>
</ul>
)
}Estilos para os modos claro e escuro
No Tailwind, prefixamos uma classe com dark: para usá-la quando o modo escuro está ativado.
Assim, o código do nosso package compartilhado deve conter os estilos tanto para a variante clara quanto para a 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
gatographql.com usa o modo escuro. Ele é definido adicionando a classe dark ao <body> no arquivo apps/gatographql/app/layout.tsx (mais as classes de estilização: 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 o modo claro. Este é o modo padrão, portanto não é necessário adicionar nenhuma classe especial ao <body> (apenas as de estilização: 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>
)
}É isso
Agora tenho 2 sites, que obtive pelo preço de 1. E estou muito feliz com isso.
Agora, vá encontrar as 7 diferenças, e resgate seu prêmio! 😅