👭 Karanlık/açık modu hackleyerek 1 fiyatına 2 Next.js sitesi oluşturmak
Yakın zamanda Gato GraphQL ekibi, Gato GraphQL'in kardeş sitesi olan Gato Plugins'ı yayına aldı.
Her ikisinin de aynı site olduğunu fark edeceksiniz! İkisi arasındaki tek fark renk şemasıdır: Gato GraphQL koyu temalıyken, Gato Plugins açık temalıdı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 eklentileri ve Gato Plugins eklentileri aynı düzeni kullanır:


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


Ve evet, bu blog yazısı her iki sitede de var! 😂
gatoplugins.com'da okuyun: Building 2 Nextjs websites at the price of 1, by hacking the light/dark mode.
Ancak, iki sitedeki yazılar arasında tam olarak 7 fark vardır. Hepsini bulabilir misiniz? Bulursanız, Gato GraphQL için indirimli bir kupon vereceğim 🙏
2 web sitesi üretmek için neden açık/koyu modları kullandık
Birden fazla neden var:
İki ayrı kod tabanını sürdürmek için zamanım ya da enerjim yok. İşleri basit tutmam gerekiyor.
Web sitesinde geçirdiğim her saat, ürünlerimden birinde geçirmediğim bir saattir.
Birbirine benzer görünmelerini istiyorum, böylece kullanıcılar onları aynı ailenin parçası olarak tanıyabilsin.
Ben bir tasarımcı değilim. O görünümü ve stili elde etmiş olmaktan memnundum ve sıfırdan başlamak istemedim.
Başka bir deyişle: çünkü ucuz ve kolay. Bu bana, kendi ürünümde harcayabileceğim çok fazla zaman ve enerji kazandırdı.
Dezavantaj olarak, 2 site karanlık/açık mod geçişini destekleyemiyor, bu yüzden stilleri sabit, ama bununla yaşayabilirim.
Pekâlâ! Haydi kolları sıvayalım ve nasıl yapıldığını görelim.
Stack: Uygulama Next.js ve stil için Tailwind CSS tabanlıdır.
Cruip tarafından hazırlanan çeşitli şablonların birleşimi olarak oluşturulmuş ve ihtiyaçlarımıza göre özelleştirilmiştir. (Bu şablonlar çok güzel!)
İçerik Contentlayer aracılığıyla yönetilmektedir.
Ortak kodu paylaşılan bir pakete çıkartı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.
Repom başlangıçta tek bir proje içeriyordu:
- gatographql.com
Aşağıdaki ş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 ortak kullanılan kod
VSCode'daki çalışma alanım şöyle görünüyor:

Monorepo için süslü bir şey kullanmıyorum, basit bir workspaces işi gayet iyi yapıyor.
Monorepo kökündeki package.json dosyam şu anda şöyle görünüyor:
{
"name": "gatowebsites",
"version": "3.0.0",
"private": true,
"workspaces": [
"apps/*",
"packages/shared/*"
]
}Buna ek olarak, her iki projeyi çalıştırmak/derlemek/dağıtmak için package.json'a scriptler 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 veri için props alacak şekilde dönüştürün
Mümkün olduğunca, her iki web sitesindeki kodu paylaşılan pakete taşırız ve ardından davranışı props aracılığıyla özelleştiririz.
Örneğin, paylaşılan gatoapp paketi, her iki sitede /blog sayfasını yazdırmak 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 = "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>
)
}Tüm içerik aynıdır, şunlar hariç:
- Sayfa başlığı (title/description)
- Blog yazıları
- Kampanya banner'ı
İki web sitesi kampanyalarını birbirinden bağımsız olarak yürütebildiğinden, campaignBanner'ı React.ReactNode olarak geçirmek kampanyaları özelleştirmeyi kısıtlamaz.
Örneğin, bu blog yazısını yayınlarken Gato GraphQL'de bir kampanya yürütüyorum, ancak Gato Plugins'te değil:

Blog yazılarını enjekte etmek biraz daha fazla mantık gerektiriyor.
Blog yazılarını enjekte etmek
Blog yazılarının verileri, blogPosts prop'u aracılığıyla BlogSection'a enjekte edilir.
Contentlayer kullandığımdan, her web sitesinin kökte site türlerini tanımlayan bir contentlayer.config.js dosyası olacaktır.
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 export modülü oluşturur ve ardından bunları her sitenin contentlayer.config.js dosyasına aktarırız, böylece mantık DRY olur.
gatoapp'in, paylaşılan BlogPost türünü sağlayan contentlayer.config.js export modülü vardır:
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,
},
}Hem apps/gatographql.com hem de apps/gatoplugins.com'daki contentlayer.config.js dosyası 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, kodumuzda BlogPost türüne başvurmak için şu şekilde içe aktarırız:
import { BlogPost } from '@/.contentlayer/generated'Ancak, BlogPost türü paylaşılan paketin altında değil, web sitesinin altında bulunur; bu nedenle paylaşılan kod bu türe doğrudan başvuramaz.
Bunu bir hack ile çözüyoruz: Derlenmiş Contentlayer dosyasından (apps/gatographql/.contentlayer/generated/types.d.ts altında) o türün tanımını kopyalar ve paylaşılan paketteki 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 sitesi ile paylaşılan paketteki BlogPost türleri arasındaki özellikler aynı olduğundan, birincisini ikincisini bekleyen bir bileşene geçirebiliriz.
Global props enjekte etmek için bir context oluşturun
Navigasyon menü bileşenleri paylaşılan kodda render edilecek, ancak her web sitesinin kendi menüleri olacağından bunların web sitesi kodu aracılığıyla sağlanması gerekir.
Menüler tüm sayfalarda görünür ve bunları her seferinde props aracılığıyla geçirmek istemeyiz. Bu nedenle, navigasyon menü bileşenlerini yalnızca bir kez enjekte etmemize olanak tanıyan bir React context kullanırız.
Paylaşılan pakette AppComponent adlı bir context oluştururuz:
'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)Bunu paylaşılan paketimizde referans alırız:
'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 ederiz:
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>
</Dropdown>
</ul>
)
}Açık ve koyu modlar için stiller
Tailwind'de, koyu mod etkinleştirildiğinde kullanmak için bir sınıfın başına dark: ekleriz.
Ardından, paylaşılan paket kodumuzun hem açık hem de koyu varyantlar için stilleri içermesi gerekir.
Örneğin, PageHeader bileşeni açık mod (text-gray-600) ve koyu mod (dark:text-slate-400) için farklı renklerle açıklamayı yazdırır:
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 koyu modu ayarlayın
gatographql.com koyu modu kullanır. Bunu apps/gatographql/app/layout.tsx dosyasındaki <body>'ye dark sınıf adını ekleyerek tanımlar (artı stil için 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, bu nedenle <body>'ye herhangi bir özel sınıf adı eklemeye gerek yoktur (yalnızca stil için olanlar: 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>
)
}Hepsi bu kadar
Artık 1 fiyatına 2 web siteme sahibim. Ve bundan çok mutluyum.
Şimdi, 7 farkı bulmaya gidin ve ödülünüzü alın! 😅