Blog

👭 Construir 2 sitios Next.js al precio de 1, trucando el modo claro/oscuro

Leonardo Losoviz
Por Leonardo Losoviz ·

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:

Sección del blog en gatographql.com
Sección del blog en gatographql.com
Sección del blog en gatoplugins.com
Sección del blog en gatoplugins.com

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

Sección de docs en gatographql.com
Sección de docs en gatographql.com
Sección de docs en gatoplugins.com
Sección de docs en gatoplugins.com

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:

Sección de extensiones en gatographql.com
Sección de extensiones en gatographql.com
Sección de plugins en gatoplugins.com
Sección de plugins en gatoplugins.com

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

Logo en gatographql.com
Logo en gatographql.com
Logo en gatoplugins.com
Logo en gatoplugins.com

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:

Estructura de mi monorepo
Estructura de mi monorepo

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:

Banner de campaña en gatographql.com
Banner de campaña en gatographql.com

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


Descubre lo que viene

Suscríbete a nuestra newsletter: te avisamos cuando publicamos una nueva versión, lanzamos un nuevo plugin o tenemos novedades para ti.