👭 Construir 2 sitios Next.js al precio de 1, trucando el modo claro/oscuro
Recientemente el equipo de Gato GraphQL ha lanzado Gato Plugins, un sitio hermano de Gato GraphQL.
Notarás que son el mismo sitio. La única diferencia es el esquema de colores: Gato GraphQL usa tema oscuro, mientras que Gato Plugins usa tema claro.
La sección del blog en ambos sitios es exactamente igual:


La sección de docs también es la misma:


A veces la sección es diferente, pero la base subyacente es la misma.
Por ejemplo, las extensiones de Gato GraphQL y los plugins de Gato Plugins usan el mismo layout:


(Por cierto, ¡los logos también son casi iguales! 😜)


Y sí, ¡este post también está en los dos sitios! 😂
Léelo en gatographql.com: Building 2 Nextjs websites at the price of 1, by hacking the dark/light mode.
Pero hay exactamente 7 diferencias entre los posts de los dos sitios. ¿Puedes encontrarlas todas? Si lo consigues, te regalo un cupón con un descuento para Gato GraphQL 🙏
Por qué usamos los modos claro/oscuro para producir 2 sitios web
Hay varias razones:
No tengo el tiempo ni la energía para mantener dos bases de código separadas. Necesito mantener las cosas simples.
Cada hora que dedico al sitio es una hora que no dedico a ninguno de mis productos.
Quiero que se parezcan, para que la gente los reconozca como parte de la misma familia.
No soy diseñador. Habiendo logrado ese aspecto y estilo, estaba satisfecho y no quería empezar de cero.
Dicho de otro modo: porque es barato y fácil. Me ahorró toneladas de tiempo y energía que pude dedicar a mi propio producto.
Como contrapartida, los 2 sitios no pueden ofrecer el toggle de modo claro/oscuro, así que su estilo queda fijo, pero con eso puedo vivir.
¡Muy bien! Pongámonos manos a la obra y veamos cómo se hizo.
Stack: la aplicación está basada en Next.js y usa Tailwind CSS para los estilos.
Se creó como combinación de varias plantillas de Cruip, personalizadas según nuestras necesidades. (¡Esas plantillas son preciosas!)
El contenido se gestiona con Contentlayer.
Extraer el código común a un paquete compartido y alojarlo todo en un monorepo
Como la base de código de ambos sitios es la misma, tiene todo el sentido alojarlos juntos en un monorepo.
Mi repositorio originalmente tenía un único proyecto:
- gatographql.com
Lo reestructuré así:
- apps/gatographql.com: sitio de Gato GraphQL
- apps/gatoplugins.com: sitio de Gato Plugins
- packages/shared/gatoapp: código compartido entre ambos sitios
Este es mi workspace en VSCode:

No uso nada sofisticado para el monorepo, un simple workspaces hace el trabajo bien.
Mi package.json en la raíz del monorepo queda así:
{
"name": "gatowebsites",
"version": "2.0.0",
"private": true,
"workspaces": [
"apps/*",
"packages/shared/*"
]
}Además, añadí scripts a package.json para ejecutar/compilar/desplegar ambos proyectos (incluido el despliegue a Netlify, donde están alojados los dos):
{
"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"
}
}Convertir los componentes para que reciban props con datos personalizados
En la medida de lo posible movemos código desde cada uno de los sitios al paquete compartido y luego personalizamos el comportamiento mediante props.
Por ejemplo, el paquete compartido gatoapp contiene un componente BlogSection (que pinta la página /blog en los dos sitios):
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 el contenido es el mismo salvo por:
- La cabecera de la página (título/descripción)
- Los posts del blog
- El banner de campaña
Como los dos sitios pueden ejecutar sus propias campañas de forma independiente, pasar campaignBanner como un React.ReactNode no limita la personalización.
Por ejemplo, mientras publico este post estoy ejecutando una campaña en Gato GraphQL, pero no en Gato Plugins:

Para inyectar los posts del blog hace falta algo más de lógica.
Inyectar los posts del blog
Los datos de los posts se inyectan a BlogSection mediante la prop blogPosts.
Como uso Contentlayer, cada sitio tendrá un archivo contentlayer.config.js en la raíz que define los tipos del sitio.
Este archivo de configuración no se puede mover al paquete compartido gatoapp. Lo que hacemos es crear un módulo que exporte la configuración para los tipos compartidos y luego importarla en el contentlayer.config.js de cada sitio, manteniendo la lógica DRY.
gatoapp tiene un módulo de exportación contentlayer.config.js que ofrece el tipo compartido 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,
},
}El archivo contentlayer.config.js tanto en apps/gatographql.com como en apps/gatoplugins.com puede importar ese 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 el tipo BlogPost en nuestro código, lo importaríamos así:
import { BlogPost } from '@/.contentlayer/generated'Sin embargo, el tipo BlogPost vive bajo el sitio, no bajo el paquete compartido, así que el código compartido no puede referenciar ese tipo directamente.
Lo resolvemos con un truco: copiamos la definición de ese tipo desde el archivo compilado de Contentlayer (en apps/gatographql/.contentlayer/generated/types.d.ts) y la pegamos en un nuevo archivo types.tsx del paquete compartido:
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,
}Después referenciamos este tipo compartido en el código compartido:
import { BlogPost } from 'gatoapp/types'Como las propiedades entre los tipos BlogPost del sitio y del paquete compartido son las mismas, podemos pasar el primero a un componente que espere el segundo.
Crear un contexto para inyectar props globales
Los componentes de menú de navegación se pintan en el código compartido, pero los proporciona el código del sitio, ya que cada sitio tendrá sus propios menús.
Los menús aparecen en todas las páginas y no queremos tener que pasarlos como props una y otra vez. Por eso usamos un contexto de React, que nos permite inyectar los componentes del menú de navegación una sola vez.
Creamos un contexto llamado AppComponent en el paquete compartido:
'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 referenciamos en nuestro paquete compartido:
'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>
)
}Y lo inyectamos desde el código del sitio, en 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 último, el sitio implementa su propio 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 los modos claro y oscuro
En Tailwind, anteponemos dark: a una clase para que se aplique cuando el modo oscuro está activo.
Entonces, el código de nuestro paquete compartido debe contener los estilos para las dos variantes, clara y oscura.
Por ejemplo, el componente PageHeader pinta la descripción con colores distintos para el modo claro (text-gray-600) y el modo oscuro (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>
)
}Fijar el modo claro u oscuro en el sitio
gatographql.com usa el modo oscuro. Lo define añadiendo la clase dark al <body> en el archivo apps/gatographql/app/layout.tsx (junto con clases 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>
)
}gatoplugins.com usa el modo claro. Es el modo por defecto, así que no hace falta añadir ninguna clase concreta al <body> (solo las 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>
)
}Y ya está
Ahora tengo 2 sitios web por el precio de 1. Y estoy muy contento con ello.
¡Ahora ve a buscar las 7 diferencias y reclama tu premio! 😅