👭 1 fiyatına 2 Next.js sitesi, açık/karanlık modu hackleyerek
Son zamanlarda Gato GraphQL ekibi, Gato GraphQL'in kardeş sitesi olan Gato Plugins'i hayata geçirdi.
Her ikisinin de aynı site olduğunu fark edeceksiniz! İkisi arasındaki tek fark renk şemasıdır: Gato GraphQL koyu tema kullanırken Gato Plugins açık tema kullanır.
Her iki sitedeki blog bölümü tamamen aynıdır:


Docs bölümü de aynıdır:


Bazen bölüm farklı olsa da altta yatan temel aynıdır.
Örneğin, Gato GraphQL uzantıları ve Gato Plugins eklentileri aynı düzeni kullanır:


(Bu arada, logolar da neredeyse aynı! 😜)


Ve evet, bu blog yazısı da her iki sitede mevcut! 😂
gatographql.com'da okuyun: Building 2 Nextjs websites at the price of 1, by hacking the dark/light mode.
Ancak iki sitedeki yazılar arasında tam olarak 7 fark var. Hepsini bulabilir misiniz? Eğer bulursanız, Gato GraphQL için indirimli bir kupon vereceğim 🙏
2 web sitesi üretmek için neden açık/karanlık modları kullandık
Birden fazla neden var:
İki ayrı kod tabanını sürdürecek zamanım veya enerjim yok. Her şeyi basit tutmam gerekiyor.
Web sitesine harcadığım her saat, ürünlerimden birine harcamadığım bir saattir.
Benzer görünmelerini istiyorum ki kullanıcılar onları aynı ailenin parçası olarak tanısın.
Ben bir tasarımcı değilim. O görünümü ve stili elde edince memnun oldum ve sıfırdan başlamak istemedim.
Başka bir deyişle: çünkü ucuz ve kolay. Bu bana tonlarca zaman ve enerji kazandırdı; bunları kendi ürünüme harcayabildim.
Dezavantaj olarak, 2 site karanlık/açık mod geçişini destekleyemiyor, dolayısıyla stilleri sabit kalıyor, ancak bununla yaşayabileceğim bir şey.
Pekâlâ! Haydi kolları sıvayalım ve nasıl yapıldığına bakalım.
Stack: Uygulama Next.js ve stil için Tailwind CSS üzerine kuruludur.
Cruip tarafından sunulan çeşitli şablonların birleştirilmesiyle ihtiyaçlarımıza göre özelleştirilmiştir. (Bu şablonlar gerçekten güzel!)
İçerik Contentlayer aracılığıyla yönetilmektedir.
Ortak kodu paylaşılan bir pakete çıkarın ve her şeyi bir monorepo'da barındırın
Her iki web sitesinin kod tabanı aynı olduğundan, hepsini bir monorepo'da birlikte barındırmak mantıklıdır.
Benim repo'um başlangıçta tek bir projeye sahipti:
- gatographql.com
Şu şekilde yeniden yapılandırıldı:
- apps/gatographql.com: Gato GraphQL web sitesi
- apps/gatoplugins.com: Gato Plugins web sitesi
- packages/shared/gatoapp: Her iki web sitesinde paylaşılan kod
VSCode'daki çalışma alanım şu şekilde:

Monorepo için süslü bir şey kullanmıyorum; basit bir workspaces işi çok iyi yapıyor.
Monorepo'nun kökündeki package.json dosyam artık şöyle görünüyor:
{
"name": "gatowebsites",
"version": "2.0.0",
"private": true,
"workspaces": [
"apps/*",
"packages/shared/*"
]
}Bunun yanı sıra, her iki projeyi de çalıştırmak/derlemek/dağıtmak için package.json dosyasına betikler ekledim (her ikisinin de barındırıldığı Netlify'a dağıtım dahil):
{
"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"
}
}Bileşenleri özel veriler için prop alacak şekilde dönüştürün
Mümkün olduğunca, her web sitesinin kodunu paylaşılan pakete taşır ve ardından davranışı prop'lar aracılığıyla özelleştiririz.
Örneğin, paylaşılan gatoapp paketi her iki sitede /blog sayfasını göstermek için bir BlogSection bileşeni içerir:
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>
)
}Şunlar dışında tüm içerik aynıdır:
- Sayfa başlığı (başlık/açıklama)
- Blog yazıları
- Kampanya banner'ı
İki web sitesi birbirinden bağımsız olarak kendi kampanyalarını yürütebildiğinden, campaignBanner'ı React.ReactNode olarak geçirmek kampanya özelleştirmesini kısıtlamaz.
Örneğin, bu blog yazısını yayınlarken Gato GraphQL'de bir kampanya yürütüyorum, ancak Gato Plugins'de yürütmüyorum:

Blog yazılarını enjekte etmek biraz daha fazla mantık gerektiriyor.
Blog yazılarını enjekte etme
Blog yazılarına ait veriler BlogSection'a blogPosts prop'u aracılığıyla enjekte edilir.
Contentlayer kullandığımdan, her web sitesinin kökünde sitedeki türleri tanımlayan bir contentlayer.config.js dosyası bulunur.
Bu yapılandırma dosyası paylaşılan gatoapp'e taşınamaz. Bu nedenle, paylaşılan türlerin yapılandırmasını sağlamak için bir dışa aktarma modülü oluşturur ve ardından bunları her sitedeki contentlayer.config.js dosyasına aktararak mantığı DRY hale getiririz.
gatoapp, paylaşılan BlogPost türünü sağlayan bir contentlayer.config.js dışa aktarma modülüne sahiptir:
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,
},
}contentlayer.config.js dosyası hem apps/gatographql.com hem de apps/gatoplugins.com'da bu türü içe aktarabilir:
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],
})Normalde BlogPost türüne kodumuzda başvurmak için şu şekilde içe aktarırdık:
import { BlogPost } from '@/.contentlayer/generated'Ancak BlogPost türü paylaşılan paketin altında değil, web sitesinin altında bulunduğundan, paylaşılan kod bu türe doğrudan başvuramaz.
Bunu bir hile ile çözüyoruz: Derlenen Contentlayer dosyasından (apps/gatographql/.contentlayer/generated/types.d.ts altında) bu türün tanımını kopyalar ve paylaşılan pakette yeni bir types.tsx dosyasına yapıştırırız:
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,
}Ardından bu paylaşılan türe paylaşılan kodda başvururuz:
import { BlogPost } from 'gatoapp/types'Web sitesindeki ve paylaşılan paketteki BlogPost türleri arasındaki özellikler aynı olduğundan, birincisini ikincisini bekleyen bir bileşene geçirebiliriz.
Global prop'ları enjekte etmek için bir bağlam oluşturma
Navigasyon menüsü bileşenleri paylaşılan kodda gösterilecek, ancak her web sitesinin kendi menüleri olacağından bunların web sitesi kodu tarafından sağlanması gerekir.
Menüler tüm sayfalarda görünür ve bunları her seferinde prop'lar aracılığıyla geçirmek istemiyoruz. Bu nedenle, navigasyon menüsü bileşenlerini yalnızca bir kez enjekte etmemize olanak tanıyan bir React bağlamı kullanıyoruz.
Paylaşılan pakette AppComponent adında bir bağlam oluşturuyoruz:
'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)Buna paylaşılan paketimizde başvuruyoruz:
'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>
)
}Ve bunu apps/gatographql/app/(default)/layout.tsx dosyasındaki web sitesi kodu aracılığıyla enjekte ediyoruz:
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>
)
}Son olarak, web sitesi kendi HeaderMenu bileşenini uygular:
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>
)
}Açık ve karanlık modlar için stiller
Tailwind'de, karanlık mod etkinleştirildiğinde kullanmak için bir sınıfın önüne dark: ekleriz.
Paylaşılan paket kodumuzdaki stiller hem açık hem de karanlık varyantları içermelidir.
Örneğin, PageHeader bileşeni açıklama metnini açık mod (text-gray-600) ve karanlık mod (dark:text-slate-400) için farklı renklerle gösterir:
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>
)
}Sitede açık veya karanlık modu ayarlama
gatographql.com karanlık modu kullanır. Bunu apps/gatographql/app/layout.tsx dosyasındaki <body> etiketine dark sınıf adını ekleyerek tanımlar (artı stillendirme sınıf adları: 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 açık modu kullanır. Bu varsayılan moddur, dolayısıyla <body> etiketine özel bir sınıf adı eklemeye gerek yoktur (yalnızca stillendirme için olanlar: 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>
)
}İşte bu kadar
Artık 1 fiyatına 2 web sitesine sahibim. Ve bundan çok mutluyum.
Şimdi, 7 farkı bulmaya gidin ve ödülünüzü alın! 😅