👭 Budowanie 2 stron Next.js w cenie 1, przez hakowanie trybu ciemnego/jasnego
Niedawno zespół Gato GraphQL uruchomił Gato Plugins, stronę siostrzaną dla 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:


Czasami sekcja jest inna, jednak leżące u podstaw fundamenty są takie same.
Na przykład, rozszerzenia Gato GraphQL i wtyczki Gato Plugins używają tego samego układu:


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


I tak, ten wpis na blogu jest też na obu stronach! 😂
Przeczytaj na gatoplugins.com: Building 2 Nextjs websites at the price of 1, by hacking the light/dark mode.
Jednak jest dokładnie 7 różnic między wpisami na obu stronach. Czy potrafisz 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 na utrzymywanie dwóch oddzielnych baz kodu. Muszę trzymać rzeczy w prostocie.
Każda godzina spędzona na stronie to godzina, której nie poświęcam żadnemu z moich produktów.
Chcę, żeby wyglądały podobnie, żeby użytkownicy mogli rozpoznać 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 łatwe. Zaoszczędziłem mnóstwo czasu i energii, które mogłem poświęcić na własny produkt.
Jako wadę, 2 strony nie mogą obsługiwać przełącznika trybu ciemnego/jasnego, więc ich styl jest stały, ale z tym mogę żyć.
No to do dzieła! Zobaczmy więc, jak to zostało zrobione.
Stack: Aplikacja oparta jest na Next.js oraz Tailwind CSS do stylizacji.
Została stworzona jako kombinacja kilku szablonów od Cruip, dostosowanych do naszych potrzeb. (Te szablony są piękne!)
Treść jest zarządzana przez Contentlayer.
Wyodrębnij wspólny kod do współdzielonego pakietu i umieść wszystko w monorepo
Ponieważ baza kodu dla obu stron jest taka sama, sensowne jest trzymanie ich wszystkich razem w monorepo.
Moje repozytorium pierwotnie miało jeden projekt:
- gatographql.com
Zostało zrestrukturyzowane w następujący sposób:
- apps/gatographql.com: Strona Gato GraphQL
- apps/gatoplugins.com: Strona Gato Plugins
- packages/shared/gatoapp: Współdzielony kod między obiema stronami
To jest mój workspace w VSCode:

Nie używam niczego wymyślnego do monorepo, prosty workspaces dobrze spełnia swoje zadanie.
Mój package.json w katalogu głównym monorepo wygląda teraz tak:
{
"name": "gatowebsites",
"version": "3.0.0",
"private": true,
"workspaces": [
"apps/*",
"packages/shared/*"
]
}Ponadto dodałem skrypty do package.json, aby uruchamiać/budować/wdrażać oba projekty (w tym wdrożenie 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, aby przyjmowały props z niestandardowymi danymi
W miarę możliwości przenosimy kod z każdej ze stron do współdzielonego pakietu, a następnie dostosowujemy zachowanie przez props.
Na przykład, współdzielony pakiet 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 = "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)
- Wpisów na blogu
- Banera kampanii
Ponieważ obie strony mogą prowadzić własne kampanie niezależnie od siebie, przekazanie campaignBanner jako React.ReactNode nie ogranicza dostosowywania kampanii.
Na przykład, gdy publikuję ten wpis na blogu, prowadzę kampanię w Gato GraphQL, ale nie w Gato Plugins:

Wstrzyknięcie wpisów bloga wymaga nieco więcej logiki.
Wstrzykiwanie wpisów bloga
Dane wpisów bloga są wstrzykiwane do BlogSection przez 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.
Tego pliku konfiguracyjnego nie można przenieść do współdzielonego gatoapp. Dlatego tworzymy moduł eksportowy, aby dostarczyć konfigurację dla współdzielonych typów, a następnie importujemy je w contentlayer.config.js dla każdej strony, dzięki czemu logika jest 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 wtedy 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 znajduje się pod stroną, a nie pod współdzielonym pakietem, więc współdzielony kod nie może bezpośrednio odwoływać się do tego typu.
Rozwiązujemy to za pomocą hacka: kopiujemy definicję tego typu ze skompilowanego pliku Contentlayer (znajdującego się w apps/gatographql/.contentlayer/generated/types.d.ts) i wklejamy ją do nowego pliku types.tsx w współdzielonym pakiecie:
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 współdzielonym kodzie:
import { BlogPost } from 'gatoapp/types'Ponieważ właściwości między typami BlogPost na stronie i we współdzielonym pakiecie są takie same, możemy przekazać pierwszy do komponentu, który oczekuje drugiego.
Utwórz kontekst do wstrzykiwania globalnych props
Komponenty menu nawigacji będą renderowane we współdzielonym kodzie, ale muszą być dostarczone przez kod strony, ponieważ każda strona będzie miała własne menu.
Menu pojawiają się na wszystkich stronach i nie chcemy ich przekazywać przez props raz za razem. Dlatego używamy kontekstu React, który pozwala nam wstrzyknąć komponenty menu nawigacji tylko raz.
Tworzymy kontekst o nazwie AppComponent we współdzielonym pakiecie:
'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 współdzielonym pakiecie:
'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>
</Dropdown>
</ul>
)
}Style dla trybów jasnego i ciemnego
W Tailwind poprzedzamy klasę przedrostkiem dark:, aby używać jej gdy tryb ciemny jest włączony.
Dlatego nasz kod współdzielonego pakietu musi zawierać style zarówno dla wariantu jasnego, jak i ciemnego.
Na przykład, komponent PageHeader wyświetla opis w różnych kolorach dla trybu jasnego (text-gray-600) i 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. Jest on zdefiniowany 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 szczególnej klasy do <body> (tylko klasy stylizacji: 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>
)
}To wszystko
Mam teraz 2 strony, które uzyskałem w cenie 1. I jestem z tego bardzo zadowolony.
Teraz, znajdź 7 różnic i odbierz swoją nagrodę! 😅