Blog

👭 Construyendo 2 sitios web Next.js al precio de 1, aprovechando los modos claro/oscuro

Leonardo Losoviz
Por Leonardo Losoviz ·

Recientemente, el equipo de Gato GraphQL ha lanzado Gato Plugins, un sitio hermano de Gato GraphQL.

¡Te darás cuenta de que son el mismo sitio! La única diferencia entre ambos es el esquema de colores: Gato GraphQL tiene tema oscuro, mientras que Gato Plugins tiene tema claro.

La sección de blog en ambos sitios es exactamente la misma:

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

La sección de documentación 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 extensions de Gato GraphQL y los plugins de Gato Plugins usan el mismo diseño:

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 prácticamente iguales! 😜)

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

¡Y sí, este artículo también está en ambos sitios! 😂

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

Sin embargo, hay exactamente 7 diferencias entre los artículos de los dos sitios. ¿Puedes encontrarlas todas? Si lo consigues, te daré un cupón con un descuento para Gato GraphQL 🙏

Por qué usamos los modos claro/oscuro para producir 2 sitios web

Hay múltiples razones:

No tengo tiempo ni energía para mantener dos bases de código separadas. Necesito mantener las cosas simples.

Cada hora que dedico al sitio web es una hora que no dedico a ninguno de mis productos.

Quiero que se parezcan, para que los usuarios puedan reconocerlos como parte de la misma familia.

No soy diseñador. Habiendo conseguido ese aspecto y estilo, estaba satisfecho, y no quería empezar de cero.

En otras palabras: porque es barato y fácil. Me ahorró toneladas de tiempo y energía, que podía dedicar a mi propio producto.

Como desventaja, los 2 sitios no pueden soportar el cambio entre modo claro/oscuro, por lo que su estilo es fijo, pero es algo con lo que 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 Tailwind CSS para los estilos.

Fue creada como una combinación de varias plantillas de Cruip, personalizadas según nuestras necesidades. (¡Esas plantillas son preciosas!)

El contenido se gestiona mediante Contentlayer.

Extraer el código común a un paquete compartido y alojar todo en un monorepo

Dado que el código base para ambos sitios web es el mismo, tiene sentido alojarlos todos juntos en un monorepo.

Originalmente, mi repo tenía un único proyecto:

  • gatographql.com

Se reestructuró de la siguiente forma:

  • apps/gatographql.com: Sitio web de Gato GraphQL
  • apps/gatoplugins.com: Sitio web de Gato Plugins
  • packages/shared/gatoapp: Código compartido entre ambos sitios

Este es mi espacio de trabajo en VSCode:

La estructura de mi monorepo
La 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 ahora se ve así:

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

Además, añadí scripts a package.json para ejecutar/construir/desplegar ambos proyectos (incluyendo el despliegue en Netlify, donde ambos están alojados):

{
  "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 recibir props con datos personalizados

En la medida de lo posible, movemos el código de cada uno de los sitios web al paquete compartido y luego personalizamos el comportamiento mediante props.

Por ejemplo, el paquete compartido gatoapp contiene un componente BlogSection (para mostrar la página /blog en ambos 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 = "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, excepto por:

  • La cabecera de la página (título/descripción)
  • Las entradas del blog
  • El banner de la campaña

Dado que los dos sitios web pueden ejecutar sus propias campañas de forma independiente, pasar campaignBanner como React.ReactNode no limita la personalización de las campañas.

Por ejemplo, mientras publico esta entrada de blog, 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 las entradas del blog, hace falta un poco más de lógica.

Inyectando entradas del blog

Los datos de las entradas del blog se inyectan a BlogSection mediante el prop blogPosts.

Como estoy usando Contentlayer, cada sitio tendrá un archivo contentlayer.config.js en la raíz, definiendo los tipos del sitio.

Este archivo de configuración no se puede mover al paquete compartido gatoapp. Entonces, creamos un módulo de exportación para proporcionar la configuración para los tipos compartidos, y luego los importamos en el contentlayer.config.js de cada sitio, haciendo la lógica DRY.

gatoapp tiene un módulo de exportación contentlayer.config.js que proporciona 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 entonces 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 reside bajo el sitio web, no bajo el paquete compartido, por lo que el código compartido no puede referenciar directamente ese tipo.

Resolvemos esto con un truco: copiamos la definición de ese tipo del archivo compilado de Contentlayer (en apps/gatographql/.contentlayer/generated/types.d.ts), y la pegamos en un nuevo archivo types.tsx en el 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,
}

Entonces referenciamos este tipo compartido en el código compartido:

import { BlogPost } from 'gatoapp/types'

Dado que las propiedades entre los tipos BlogPost del sitio web y del paquete compartido son las mismas, podemos pasar el primero a un componente que espera el segundo.

Crear un contexto para inyectar props globales

Los componentes del menú de navegación se renderizarán en el código compartido, pero deben proporcionarse mediante el código del sitio web, ya que cada sitio tendrá sus propios menús.

Los menús aparecen en todas las páginas, y no queremos tener que pasarlos mediante 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 mediante el código del sitio web, 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>
  )
}

Finalmente, el sitio web 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="/precios">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 los modos claro y oscuro

En Tailwind, prefijamos una clase con dark: para usarla cuando el modo oscuro está habilitado.

Por lo tanto, el código de nuestro paquete compartido debe contener los estilos para las variantes claras y oscuras.

Por ejemplo, el componente PageHeader muestra la descripción con diferentes colores 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>
  )
}

Establecer 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 (más las clases para el 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 particular al <body> (solo las de estilo: 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>
  )
}

Eso es todo

Ahora tengo 2 sitios web, que conseguí por el precio de 1. Y estoy muy contento con eso.

Ahora, ve a encontrar las 7 diferencias, ¡y consigue tu premio! 😅


Suscríbete a nuestra newsletter

Mantente al tanto de todas las novedades de Gato GraphQL.