👭 Budowanie 2 stron Next.js w cenie 1, przez hackowanie trybu jasnego/ciemnego
Niedawno zespół Gato GraphQL uruchomił Gato Plugins — siostrzaną stronę do Gato GraphQL.
Zauważysz, że obie strony są takie same! Jedyna różnica między nimi to schemat kolorów: Gato GraphQL ma ciemny motyw, podczas gdy Gato Plugins ma jasny motyw.
Sekcja bloga na obu stronach jest dokładnie taka sama:


Sekcja dokumentacji jest również taka sama:


Czasem sekcja jest inna, jednak leżąca u jej podstaw fundacja jest taka sama.
Na przykład rozszerzenia Gato GraphQL i wtyczki Gato Plugins używają tego samego układu:


(Przy okazji, logo też są praktycznie takie same! 😜)


I tak, ten artykuł również jest na obu stronach! 😂
Przeczytaj na gatographql.com: Building 2 Nextjs websites at the price of 1, by hacking the dark/light mode.
Jednak jest dokładnie 7 różnic między artykułami na obu stronach. Czy możesz je wszystkie znaleźć? Jeśli tak, dam ci kupon ze zniżką na Gato GraphQL 🙏
Dlaczego użyliśmy trybów jasnego/ciemnego, aby stworzyć 2 strony
Jest kilka powodów:
Nie mam czasu ani energii, aby utrzymywać dwie oddzielne bazy kodu. Muszę dbać o prostotę.
Każda godzina poświęcona stronie to godzina, której nie poświęcam żadnemu ze swoich produktów.
Chcę, żeby wyglądały podobnie, aby użytkownicy rozpoznawali je jako część tej samej rodziny.
Nie jestem projektantem. Osiągnąwszy ten wygląd i styl, byłem zadowolony i nie chciałem zaczynać od zera.
Innymi słowy: dlatego że jest to tanie i proste. Zaoszczędziło mi to mnóstwo czasu i energii, które mogłem poświęcić własnemu produktowi.
Jako wadę, 2 strony nie obsługują przełącznika między trybem jasnym i ciemnym, więc ich styl jest stały — ale to coś, z czym mogę żyć.
No dobrze! Zakasajmy więc rękawy i zobaczmy, jak to zostało zrobione.
Stack: Aplikacja jest oparta na Next.js i używa Tailwind CSS do stylizacji.
Została stworzona jako połączenie kilku szablonów od Cruip, dostosowanych do naszych potrzeb. (Te szablony są piękne!)
Treścią zarządza się przez Contentlayer.
Wyodrębnij wspólny kod do pakietu współdzielonego i hostuj wszystko w monorepo
Ponieważ baza kodu obu stron jest taka sama, ma sens hostowanie ich razem w monorepo.
Moje repozytorium początkowo zawierało jeden projekt:
- gatographql.com
Zostało ono zrestrukturyzowane w następujący sposób:
- apps/gatographql.com: Strona Gato GraphQL
- apps/gatoplugins.com: Strona Gato Plugins
- packages/shared/gatoapp: Wspólny kod obu stron
To jest moje środowisko pracy w VSCode:

Nie używam niczego wymyślnego do monorepo — zwykłe workspaces doskonale spełnia zadanie.
Mój plik package.json w katalogu głównym monorepo wygląda teraz tak:
{
"name": "gatowebsites",
"version": "2.0.0",
"private": true,
"workspaces": [
"apps/*",
"packages/shared/*"
]
}Ponadto dodałem skrypty do package.json, aby uruchamiać/budować/wdrażać oba projekty (w tym wdrażanie do Netlify, gdzie oba są hostowane):
{
"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"
}
}Przekształć komponenty tak, aby przyjmowały props z niestandardowymi danymi
W miarę możliwości przenosimy kod z każdej ze stron do pakietu współdzielonego, a następnie dostosowujemy zachowanie za pomocą props.
Na przykład pakiet współdzielony gatoapp zawiera komponent BlogSection (do wyświetlania strony /blog na obu stronach):
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>
)
}Cała zawartość jest taka sama, z wyjątkiem:
- Nagłówka strony (tytuł/opis)
- Artykułów na blogu
- Banera kampanii
Ponieważ obie strony mogą niezależnie od siebie prowadzić własne kampanie, przekazanie campaignBanner jako React.ReactNode nie ogranicza możliwości dostosowania kampanii.
Na przykład w momencie publikowania tego artykułu prowadzę kampanię w Gato GraphQL, ale nie w Gato Plugins:

Aby wstrzyknąć artykuły blogu, potrzeba nieco więcej logiki.
Wstrzykiwanie artykułów blogu
Dane artykułów blogu są wstrzykiwane do BlogSection za pomocą prop blogPosts.
Ponieważ używam Contentlayer, każda strona będzie miała plik contentlayer.config.js w katalogu głównym, definiujący typy na stronie.
Ten plik konfiguracyjny nie może zostać przeniesiony do współdzielonego pakietu gatoapp. Dlatego tworzymy moduł eksportowy, aby dostarczyć konfigurację dla współdzielonych typów, a następnie importujemy je w contentlayer.config.js każdej strony, zachowując logikę DRY.
gatoapp ma moduł eksportowy contentlayer.config.js dostarczający współdzielony typ 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,
},
}Plik contentlayer.config.js zarówno w apps/gatographql.com, jak i w apps/gatoplugins.com może następnie zaimportować ten typ:
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],
})Normalnie, aby odwołać się do typu BlogPost w naszym kodzie, importowalibyśmy go w taki sposób:
import { BlogPost } from '@/.contentlayer/generated'Jednak typ BlogPost żyje wewnątrz strony, a nie wewnątrz pakietu współdzielonego, więc współdzielony kod nie może bezpośrednio odwołać się do tego typu.
Rozwiązujemy to za pomocą triku: kopiujemy definicję tego typu ze skompilowanego pliku Contentlayer (w apps/gatographql/.contentlayer/generated/types.d.ts) i wklejamy ją do nowego pliku types.tsx w pakiecie współdzielonym:
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,
}Następnie odwołujemy się do tego współdzielonego typu w kodzie współdzielonym:
import { BlogPost } from 'gatoapp/types'Ponieważ właściwości między typami BlogPost ze strony i z pakietu współdzielonego są takie same, możemy przekazać pierwszy do komponentu oczekującego drugiego.
Stwórz kontekst do wstrzykiwania globalnych props
Komponenty menu nawigacyjnego będą renderowane w kodzie współdzielonym, ale muszą być dostarczane przez kod strony, ponieważ każda strona będzie miała własne menu.
Menu pojawiają się na wszystkich stronach i nie chcemy za każdym razem przekazywać ich przez props. Dlatego używamy kontekstu React, który pozwala nam wstrzyknąć komponenty menu nawigacyjnego tylko raz.
Tworzymy kontekst o nazwie AppComponent w pakiecie współdzielonym:
'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)Odwołujemy się do niego w naszym pakiecie współdzielonym:
'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>
)
}I wstrzykujemy go przez kod strony, w 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>
)
}Na koniec strona implementuje własny komponent 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>
)
}Style dla trybu jasnego i ciemnego
W Tailwind poprzedzamy klasę prefiksem dark:, aby użyć jej, gdy tryb ciemny jest włączony.
Zatem kod naszego pakietu współdzielonego musi zawierać style dla obu wariantów — jasnego i ciemnego.
Na przykład komponent PageHeader wyświetla opis z różnymi kolorami dla trybu jasnego (text-gray-600) i trybu ciemnego (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>
)
}Ustaw tryb jasny lub ciemny na stronie
gatographql.com używa trybu ciemnego. Definiuje go przez dodanie klasy dark do <body> w pliku apps/gatographql/app/layout.tsx (plus klasy stylizacji: 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 używa trybu jasnego. Jest to tryb domyślny, więc nie ma potrzeby dodawania żadnej specjalnej klasy do <body> (tylko klasy stylizacji: 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>
)
}To wszystko
Mam teraz 2 strony, które zdobyłem w cenie 1. I jestem z tego bardzo zadowolony.
Teraz idź i znajdź 7 różnic i odbierz swoją nagrodę! 😅