๋ธ”๋กœ๊ทธ

๐Ÿ‘ญ ๋ผ์ดํŠธ/๋‹คํฌ ๋ชจ๋“œ๋ฅผ ํ™œ์šฉํ•˜์—ฌ 1๊ฐœ์˜ ๊ฐ€๊ฒฉ์œผ๋กœ 2๊ฐœ์˜ Next.js ์›น์‚ฌ์ดํŠธ ๊ตฌ์ถ•ํ•˜๊ธฐ

Leonardo Losoviz
์ž‘์„ฑ์ž: Leonardo Losoviz ยท

์ตœ๊ทผ Gato GraphQL ํŒ€์ด Gato GraphQL์˜ ํ˜•์ œ ์‚ฌ์ดํŠธ์ธ Gato Plugins๋ฅผ ์ถœ์‹œํ•˜์˜€์Šต๋‹ˆ๋‹ค.

๋‘ ์‚ฌ์ดํŠธ๊ฐ€ ์™„์ „ํžˆ ๋™์ผํ•œ ์‚ฌ์ดํŠธ๋ผ๋Š” ๊ฒƒ์„ ๋ˆˆ์น˜์ฑ„์…จ์„ ๊ฒƒ์ž…๋‹ˆ๋‹ค! ์œ ์ผํ•œ ์ฐจ์ด์ ์€ ์ƒ‰ ๊ตฌ์„ฑํ‘œ์ž…๋‹ˆ๋‹ค. Gato GraphQL์€ ๋‹คํฌ ํ…Œ๋งˆ๋ฅผ ์‚ฌ์šฉํ•˜๊ณ , Gato Plugins๋Š” ๋ผ์ดํŠธ ํ…Œ๋งˆ๋ฅผ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.

๋‘ ์‚ฌ์ดํŠธ์˜ ๋ธ”๋กœ๊ทธ ์„น์…˜์€ ์™„์ „ํžˆ ๋™์ผํ•ฉ๋‹ˆ๋‹ค:

gatographql.com์˜ ๋ธ”๋กœ๊ทธ ์„น์…˜
gatographql.com์˜ ๋ธ”๋กœ๊ทธ ์„น์…˜
gatoplugins.com์˜ ๋ธ”๋กœ๊ทธ ์„น์…˜
gatoplugins.com์˜ ๋ธ”๋กœ๊ทธ ์„น์…˜

๋ฌธ์„œ ์„น์…˜๋„ ๋™์ผํ•ฉ๋‹ˆ๋‹ค:

gatographql.com์˜ ๋ฌธ์„œ ์„น์…˜
gatographql.com์˜ ๋ฌธ์„œ ์„น์…˜
gatoplugins.com์˜ ๋ฌธ์„œ ์„น์…˜
gatoplugins.com์˜ ๋ฌธ์„œ ์„น์…˜

์„น์…˜ ๋‚ด์šฉ์ด ๋‹ค๋ฅธ ๊ฒฝ์šฐ๋„ ์žˆ์ง€๋งŒ, ๊ทผ๋ณธ์ ์ธ ๊ธฐ๋ฐ˜์€ ๋™์ผํ•ฉ๋‹ˆ๋‹ค.

์˜ˆ๋ฅผ ๋“ค์–ด, Gato GraphQL์˜ ํ™•์žฅ ๊ธฐ๋Šฅ๊ณผ Gato Plugins์˜ ํ”Œ๋Ÿฌ๊ทธ์ธ์€ ๋™์ผํ•œ ๋ ˆ์ด์•„์›ƒ์„ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค:

gatographql.com์˜ ํ™•์žฅ ๊ธฐ๋Šฅ ์„น์…˜
gatographql.com์˜ ํ™•์žฅ ๊ธฐ๋Šฅ ์„น์…˜
gatoplugins.com์˜ ํ”Œ๋Ÿฌ๊ทธ์ธ ์„น์…˜
gatoplugins.com์˜ ํ”Œ๋Ÿฌ๊ทธ์ธ ์„น์…˜

(์ฐธ๊ณ ๋กœ, ๋กœ๊ณ ๋„ ๊ฑฐ์˜ ๊ฐ™์Šต๋‹ˆ๋‹ค! ๐Ÿ˜œ)

gatographql.com์˜ ๋กœ๊ณ 
gatographql.com์˜ ๋กœ๊ณ 
gatoplugins.com์˜ ๋กœ๊ณ 
gatoplugins.com์˜ ๋กœ๊ณ 

๊ทธ๋ฆฌ๊ณ  ๋„ค, ์ด ๋ธ”๋กœ๊ทธ ๊ฒŒ์‹œ๋ฌผ๋„ ๋‘ ์‚ฌ์ดํŠธ ๋ชจ๋‘์— ๊ฒŒ์žฌ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค! ๐Ÿ˜‚

gatographql.com์—์„œ๋„ ์ฝ์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค: Building 2 Nextjs websites at the price of 1, by hacking the dark/light mode

๊ทธ๋Ÿฐ๋ฐ ๋‘ ์‚ฌ์ดํŠธ์˜ ๊ฒŒ์‹œ๋ฌผ์—๋Š” ์ •ํ™•ํžˆ 7๊ฐ€์ง€ ์ฐจ์ด์ ์ด ์žˆ์Šต๋‹ˆ๋‹ค. ๋ชจ๋‘ ์ฐพ์„ ์ˆ˜ ์žˆ์œผ์‹ ๊ฐ€์š”? ๋ชจ๋‘ ์ฐพ์œผ์‹  ๋ถ„๊ป˜๋Š” Gato GraphQL ํ• ์ธ ์ฟ ํฐ์„ ๋“œ๋ฆฝ๋‹ˆ๋‹ค ๐Ÿ™

์™œ ๋ผ์ดํŠธ/๋‹คํฌ ๋ชจ๋“œ๋ฅผ ์‚ฌ์šฉํ•ด 2๊ฐœ์˜ ์›น์‚ฌ์ดํŠธ๋ฅผ ๋งŒ๋“ค์—ˆ๋‚˜์š”

์ด์œ ๋Š” ์—ฌ๋Ÿฌ ๊ฐ€์ง€๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค:

๋‘ ๊ฐœ์˜ ๋ณ„๋„ ์ฝ”๋“œ๋ฒ ์ด์Šค๋ฅผ ์œ ์ง€ํ•  ์‹œ๊ฐ„๋„ ์—๋„ˆ์ง€๋„ ์—†์Šต๋‹ˆ๋‹ค. ๋‹จ์ˆœํ•˜๊ฒŒ ์œ ์ง€ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

์›น์‚ฌ์ดํŠธ์— ์“ฐ๋Š” ์‹œ๊ฐ„์€ ์ œํ’ˆ ๊ฐœ๋ฐœ์— ์“ธ ์ˆ˜ ์—†๋Š” ์‹œ๊ฐ„์ž…๋‹ˆ๋‹ค.

์‚ฌ์šฉ์ž๋“ค์ด ๊ฐ™์€ ๊ณ„์—ด์˜ ์‚ฌ์ดํŠธ๋กœ ์ธ์‹ํ•  ์ˆ˜ ์žˆ๋„๋ก ๋น„์Šทํ•˜๊ฒŒ ๋ณด์ด๊ธธ ์›ํ•ฉ๋‹ˆ๋‹ค.

์ €๋Š” ๋””์ž์ด๋„ˆ๊ฐ€ ์•„๋‹™๋‹ˆ๋‹ค. ๊ทธ ์™ธ๊ด€๊ณผ ์Šคํƒ€์ผ์„ ์™„์„ฑํ•œ ๊ฒƒ์— ๋งŒ์กฑํ•˜์˜€๊ณ , ์ฒ˜์Œ๋ถ€ํ„ฐ ๋‹ค์‹œ ์‹œ์ž‘ํ•˜๊ณ  ์‹ถ์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.

๋‹ค์‹œ ๋งํ•ด: ๋น„์šฉ์ด ์ €๋ ดํ•˜๊ณ  ๊ฐ„ํŽธํ•˜๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค. ์—„์ฒญ๋‚œ ์‹œ๊ฐ„๊ณผ ์—๋„ˆ์ง€๋ฅผ ์ ˆ์•ฝํ•  ์ˆ˜ ์žˆ์—ˆ๊ณ , ๊ทธ๊ฒƒ์„ ์ œ ์ œํ’ˆ์— ํ™œ์šฉํ•  ์ˆ˜ ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค.

๋‹จ์ ์œผ๋กœ๋Š”, ๋‘ ์‚ฌ์ดํŠธ๊ฐ€ ๋‹คํฌ/๋ผ์ดํŠธ ๋ชจ๋“œ ์ „ํ™˜์„ ์ง€์›ํ•  ์ˆ˜ ์—†์–ด ์Šคํƒ€์ผ์ด ๊ณ ์ •๋˜์ง€๋งŒ, ๊ทธ๊ฒƒ์€ ๊ฐ์ˆ˜ํ•  ์ˆ˜ ์žˆ๋Š” ๋ถ€๋ถ„์ž…๋‹ˆ๋‹ค.


์ž, ๊ทธ๋Ÿผ ์ง์ ‘ ์–ด๋–ป๊ฒŒ ๊ตฌํ˜„ํ–ˆ๋Š”์ง€ ์‚ดํŽด๋ณด๊ฒ ์Šต๋‹ˆ๋‹ค.

์Šคํƒ: ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์€ Next.js๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ ํ•˜๋ฉฐ, ์Šคํƒ€์ผ๋ง์—๋Š” Tailwind CSS๋ฅผ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.

Cruip์˜ ์—ฌ๋Ÿฌ ํ…œํ”Œ๋ฆฟ์„ ์กฐํ•ฉํ•˜์—ฌ ํ•„์š”์— ๋งž๊ฒŒ ์ปค์Šคํ„ฐ๋งˆ์ด์ฆˆํ•˜์—ฌ ๋งŒ๋“ค์—ˆ์Šต๋‹ˆ๋‹ค. (๊ทธ ํ…œํ”Œ๋ฆฟ๋“ค์€ ์ •๋ง ์•„๋ฆ„๋‹ต์Šต๋‹ˆ๋‹ค!)

์ฝ˜ํ…์ธ ๋Š” Contentlayer๋กœ ๊ด€๋ฆฌ๋ฉ๋‹ˆ๋‹ค.

๊ณตํ†ต ์ฝ”๋“œ๋ฅผ ๊ณต์œ  ํŒจํ‚ค์ง€๋กœ ์ถ”์ถœํ•˜๊ณ  ๋ชจ๋…ธ๋ ˆํฌ์—์„œ ํ˜ธ์ŠคํŒ…ํ•˜๊ธฐ

๋‘ ์›น์‚ฌ์ดํŠธ์˜ ์ฝ”๋“œ๋ฒ ์ด์Šค๊ฐ€ ๋™์ผํ•˜๋ฏ€๋กœ, ๋ชจ๋‘ ํ•จ๊ป˜ ๋ชจ๋…ธ๋ ˆํฌ์—์„œ ํ˜ธ์ŠคํŒ…ํ•˜๋Š” ๊ฒƒ์ด ํ•ฉ๋ฆฌ์ ์ž…๋‹ˆ๋‹ค.

์›๋ž˜ ์ €์˜ ์ €์žฅ์†Œ์—๋Š” ํ•˜๋‚˜์˜ ํ”„๋กœ์ ํŠธ๋งŒ ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค:

  • gatographql.com

๋‹ค์Œ๊ณผ ๊ฐ™์ด ์žฌ๊ตฌ์„ฑํ•˜์˜€์Šต๋‹ˆ๋‹ค:

  • apps/gatographql.com: Gato GraphQL ์›น์‚ฌ์ดํŠธ
  • apps/gatoplugins.com: Gato Plugins ์›น์‚ฌ์ดํŠธ
  • packages/shared/gatoapp: ๋‘ ์›น์‚ฌ์ดํŠธ ๊ณตํ†ต ์ฝ”๋“œ

VSCode์—์„œ์˜ ์ œ ์›Œํฌ์ŠคํŽ˜์ด์Šค๋Š” ์ด๋ ‡๊ฒŒ ์ƒ๊ฒผ์Šต๋‹ˆ๋‹ค:

๋ชจ๋…ธ๋ ˆํฌ ๊ตฌ์กฐ
๋ชจ๋…ธ๋ ˆํฌ ๊ตฌ์กฐ

๋ชจ๋…ธ๋ ˆํฌ๋ฅผ ์œ„ํ•œ ํŠน๋ณ„ํ•œ ๋„๊ตฌ๋Š” ์‚ฌ์šฉํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ๊ฐ„๋‹จํ•œ workspaces๋กœ ์ถฉ๋ถ„ํ•ฉ๋‹ˆ๋‹ค.

๋ชจ๋…ธ๋ ˆํฌ ๋ฃจํŠธ์˜ package.json์€ ๋‹ค์Œ๊ณผ ๊ฐ™์Šต๋‹ˆ๋‹ค:

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

๋˜ํ•œ ๋‘ ํ”„๋กœ์ ํŠธ๋ฅผ ์‹คํ–‰/๋นŒ๋“œ/๋ฐฐํฌํ•˜๊ธฐ ์œ„ํ•œ ์Šคํฌ๋ฆฝํŠธ๋ฅผ package.json์— ์ถ”๊ฐ€ํ•˜์˜€์Šต๋‹ˆ๋‹ค (๋‘ ์‚ฌ์ดํŠธ๊ฐ€ ํ˜ธ์ŠคํŒ…๋˜๋Š” Netlify์— ๋ฐฐํฌํ•˜๋Š” ์Šคํฌ๋ฆฝํŠธ ํฌํ•จ):

{
  "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"
  }
}

์ปดํฌ๋„ŒํŠธ๊ฐ€ ์ปค์Šคํ…€ ๋ฐ์ดํ„ฐ๋ฅผ props๋กœ ๋ฐ›๋„๋ก ๋ณ€ํ™˜ํ•˜๊ธฐ

๊ฐ€๋Šฅํ•œ ํ•œ ๊ฐ ์›น์‚ฌ์ดํŠธ์˜ ์ฝ”๋“œ๋ฅผ ๊ณต์œ  ํŒจํ‚ค์ง€๋กœ ์ด๋™ํ•˜๊ณ , props๋ฅผ ํ†ตํ•ด ๋™์ž‘์„ ์ปค์Šคํ„ฐ๋งˆ์ด์ฆˆํ•ฉ๋‹ˆ๋‹ค.

์˜ˆ๋ฅผ ๋“ค์–ด, ๊ณต์œ  ํŒจํ‚ค์ง€ gatoapp์—๋Š” BlogSection ์ปดํฌ๋„ŒํŠธ๊ฐ€ ํฌํ•จ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค (๋‘ ์‚ฌ์ดํŠธ์˜ /blog ํŽ˜์ด์ง€๋ฅผ ์ถœ๋ ฅํ•˜๊ธฐ ์œ„ํ•œ ๊ฒƒ):

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>
  )
}

์ฝ˜ํ…์ธ ๋Š” ๋ชจ๋‘ ๋™์ผํ•˜์ง€๋งŒ, ๋‹ค์Œ ํ•ญ๋ชฉ์€ ๋‹ค๋ฆ…๋‹ˆ๋‹ค:

  • ํŽ˜์ด์ง€ ํ—ค๋” (ํƒ€์ดํ‹€/์„ค๋ช…)
  • ๋ธ”๋กœ๊ทธ ๊ฒŒ์‹œ๋ฌผ
  • ์บ ํŽ˜์ธ ๋ฐฐ๋„ˆ

๋‘ ์›น์‚ฌ์ดํŠธ๊ฐ€ ์„œ๋กœ ๋…๋ฆฝ์ ์œผ๋กœ ์บ ํŽ˜์ธ์„ ์šด์˜ํ•  ์ˆ˜ ์žˆ๋„๋ก, campaignBanner๋ฅผ React.ReactNode๋กœ ์ „๋‹ฌํ•˜๋ฉด ์บ ํŽ˜์ธ ์ปค์Šคํ„ฐ๋งˆ์ด์ฆˆ์— ์ œ์•ฝ์ด ์—†์Šต๋‹ˆ๋‹ค.

์˜ˆ๋ฅผ ๋“ค์–ด, ์ด ๋ธ”๋กœ๊ทธ ๊ฒŒ์‹œ๋ฌผ์„ ๊ณต๊ฐœํ•˜๋Š” ์‹œ์ ์—๋Š” Gato GraphQL์—์„œ๋Š” ์บ ํŽ˜์ธ์„ ์ง„ํ–‰ํ•˜๊ณ  ์žˆ์ง€๋งŒ, Gato Plugins์—์„œ๋Š” ์ง„ํ–‰ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค:

gatographql.com์˜ ์บ ํŽ˜์ธ ๋ฐฐ๋„ˆ
gatographql.com์˜ ์บ ํŽ˜์ธ ๋ฐฐ๋„ˆ

๋ธ”๋กœ๊ทธ ๊ฒŒ์‹œ๋ฌผ์„ ์ฃผ์ž…ํ•˜๋ ค๋ฉด ์ข€ ๋” ๋ณต์žกํ•œ ๋กœ์ง์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.

๋ธ”๋กœ๊ทธ ๊ฒŒ์‹œ๋ฌผ ์ฃผ์ž…ํ•˜๊ธฐ

๋ธ”๋กœ๊ทธ ๊ฒŒ์‹œ๋ฌผ ๋ฐ์ดํ„ฐ๋Š” blogPosts prop์„ ํ†ตํ•ด BlogSection์— ์ฃผ์ž…๋ฉ๋‹ˆ๋‹ค.

Contentlayer๋ฅผ ์‚ฌ์šฉํ•˜๊ณ  ์žˆ์œผ๋ฏ€๋กœ, ๊ฐ ์›น์‚ฌ์ดํŠธ๋Š” ๋ฃจํŠธ์— contentlayer.config.js ํŒŒ์ผ์„ ๊ฐ–๊ณ  ์‚ฌ์ดํŠธ์˜ ํƒ€์ž…์„ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค.

์ด ์„ค์ • ํŒŒ์ผ์€ ๊ณต์œ  ํŒจํ‚ค์ง€ gatoapp์œผ๋กœ ์ด๋™ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ๋”ฐ๋ผ์„œ ๊ณต์œ  ํƒ€์ž…์˜ ์„ค์ •์„ ์ œ๊ณตํ•˜๋Š” ๋‚ด๋ณด๋‚ด๊ธฐ ๋ชจ๋“ˆ์„ ๋งŒ๋“ค๊ณ , ๊ฐ ์‚ฌ์ดํŠธ์˜ contentlayer.config.js์—์„œ ๊ฐ€์ ธ์˜ด์œผ๋กœ์จ ๋กœ์ง์„ DRYํ•˜๊ฒŒ ์œ ์ง€ํ•ฉ๋‹ˆ๋‹ค.

gatoapp์—๋Š” ๊ณต์œ  ํƒ€์ž… BlogPost๋ฅผ ์ œ๊ณตํ•˜๋Š” ๋‚ด๋ณด๋‚ด๊ธฐ ๋ชจ๋“ˆ contentlayer.config.js๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค:

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,
  },
}

apps/gatographql.com๊ณผ apps/gatoplugins.com ์–‘์ชฝ์˜ contentlayer.config.js์—์„œ ๊ทธ ํƒ€์ž…์„ ๊ฐ€์ ธ์˜ฌ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

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],
})

์ผ๋ฐ˜์ ์œผ๋กœ ์ฝ”๋“œ์—์„œ ํƒ€์ž… BlogPost๋ฅผ ์ฐธ์กฐํ•  ๋•Œ๋Š” ๋‹ค์Œ๊ณผ ๊ฐ™์ด ๊ฐ€์ ธ์˜ต๋‹ˆ๋‹ค:

import { BlogPost } from '@/.contentlayer/generated'

๊ทธ๋Ÿฌ๋‚˜ ํƒ€์ž… BlogPost๋Š” ์›น์‚ฌ์ดํŠธ ์ธก์— ์กด์žฌํ•˜๊ณ  ๊ณต์œ  ํŒจํ‚ค์ง€์—๋Š” ์—†์œผ๋ฏ€๋กœ, ๊ณต์œ  ์ฝ”๋“œ์—์„œ ๊ทธ ํƒ€์ž…์„ ์ง์ ‘ ์ฐธ์กฐํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.

์ด๋ฅผ ํ•ต(hack)์œผ๋กœ ํ•ด๊ฒฐํ•ฉ๋‹ˆ๋‹ค. ์ปดํŒŒ์ผ๋œ Contentlayer ํŒŒ์ผ(apps/gatographql/.contentlayer/generated/types.d.ts ํ•˜์œ„)์—์„œ ํ•ด๋‹น ํƒ€์ž… ์ •์˜๋ฅผ ๋ณต์‚ฌํ•˜์—ฌ ๊ณต์œ  ํŒจํ‚ค์ง€์˜ ์ƒˆ๋กœ์šด types.tsx ํŒŒ์ผ์— ๋ถ™์—ฌ๋„ฃ์Šต๋‹ˆ๋‹ค:

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,
}

๊ทธ๋Ÿฐ ๋‹ค์Œ ๊ณต์œ  ์ฝ”๋“œ์—์„œ ์ด ๊ณต์œ  ํƒ€์ž…์„ ์ฐธ์กฐํ•ฉ๋‹ˆ๋‹ค:

import { BlogPost } from 'gatoapp/types'

์›น์‚ฌ์ดํŠธ ์ธก๊ณผ ๊ณต์œ  ํŒจํ‚ค์ง€ ์ธก์˜ BlogPost ํƒ€์ž…์˜ ์†์„ฑ์ด ๋™์ผํ•˜๋ฏ€๋กœ, ์ „์ž๋ฅผ ํ›„์ž๋ฅผ ๊ธฐ๋Œ€ํ•˜๋Š” ์ปดํฌ๋„ŒํŠธ์— ์ „๋‹ฌํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๊ธ€๋กœ๋ฒŒ props๋ฅผ ์ฃผ์ž…ํ•˜๊ธฐ ์œ„ํ•œ ์ปจํ…์ŠคํŠธ ๋งŒ๋“ค๊ธฐ

๋‚ด๋น„๊ฒŒ์ด์…˜ ๋ฉ”๋‰ด ์ปดํฌ๋„ŒํŠธ๋Š” ๊ณต์œ  ์ฝ”๋“œ์—์„œ ๋ Œ๋”๋ง๋˜์ง€๋งŒ, ๊ฐ ์›น์‚ฌ์ดํŠธ๊ฐ€ ๊ณ ์œ ํ•œ ๋ฉ”๋‰ด๋ฅผ ๊ฐ€์ง€๋ฏ€๋กœ ์›น์‚ฌ์ดํŠธ ์ฝ”๋“œ๋ฅผ ํ†ตํ•ด ์ œ๊ณตํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

๋ฉ”๋‰ด๋Š” ๋ชจ๋“  ํŽ˜์ด์ง€์— ํ‘œ์‹œ๋˜๋ฏ€๋กœ ๋งค๋ฒˆ props๋กœ ์ „๋‹ฌํ•˜๊ณ  ์‹ถ์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ๋”ฐ๋ผ์„œ React ์ปจํ…์ŠคํŠธ๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ๋‚ด๋น„๊ฒŒ์ด์…˜ ๋ฉ”๋‰ด ์ปดํฌ๋„ŒํŠธ๋ฅผ ํ•œ ๋ฒˆ๋งŒ ์ฃผ์ž…ํ•  ์ˆ˜ ์žˆ๋„๋ก ํ•ฉ๋‹ˆ๋‹ค.

๊ณต์œ  ํŒจํ‚ค์ง€์— AppComponent๋ผ๋Š” ์ปจํ…์ŠคํŠธ๋ฅผ ๋งŒ๋“ญ๋‹ˆ๋‹ค:

'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)

๊ณต์œ  ํŒจํ‚ค์ง€ ๋‚ด์—์„œ ์ฐธ์กฐํ•ฉ๋‹ˆ๋‹ค:

'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>
  )
}

๊ทธ๋ฆฌ๊ณ  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>
  )
}

๋งˆ์ง€๋ง‰์œผ๋กœ, ์›น์‚ฌ์ดํŠธ๊ฐ€ ์ž์ฒด์ ์ธ 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>
  )
}

๋ผ์ดํŠธ ๋ฐ ๋‹คํฌ ๋ชจ๋“œ ์Šคํƒ€์ผ

Tailwind์—์„œ๋Š” ๋‹คํฌ ๋ชจ๋“œ๊ฐ€ ํ™œ์„ฑํ™”๋  ๋•Œ ์ ์šฉํ•  ํด๋ž˜์Šค์— dark: ์ ‘๋‘์‚ฌ๋ฅผ ๋ถ™์ž…๋‹ˆ๋‹ค.

๋”ฐ๋ผ์„œ ๊ณต์œ  ํŒจํ‚ค์ง€ ์ฝ”๋“œ์—๋Š” ๋ผ์ดํŠธ์™€ ๋‹คํฌ ์–‘์ชฝ ๋ณ€ํ˜•์˜ ์Šคํƒ€์ผ์ด ๋ชจ๋‘ ํฌํ•จ๋˜์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

์˜ˆ๋ฅผ ๋“ค์–ด, PageHeader ์ปดํฌ๋„ŒํŠธ๋Š” ๋ผ์ดํŠธ ๋ชจ๋“œ(text-gray-600)์™€ ๋‹คํฌ ๋ชจ๋“œ(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>
  )
}

์‚ฌ์ดํŠธ์— ๋ผ์ดํŠธ ๋˜๋Š” ๋‹คํฌ ๋ชจ๋“œ ์„ค์ •ํ•˜๊ธฐ

gatographql.com์€ ๋‹คํฌ ๋ชจ๋“œ๋ฅผ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. apps/gatographql/app/layout.tsx ํŒŒ์ผ์˜ <body>์— ํด๋ž˜์Šค๋ช… dark๋ฅผ ์ถ”๊ฐ€ํ•จ์œผ๋กœ์จ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค (์Šคํƒ€์ผ๋ง ํด๋ž˜์Šค๋ช… 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์€ ๋ผ์ดํŠธ ๋ชจ๋“œ๋ฅผ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. ์ด๊ฒƒ์ด ๊ธฐ๋ณธ ๋ชจ๋“œ์ด๋ฏ€๋กœ, <body>์— ํŠน๋ณ„ํ•œ ํด๋ž˜์Šค๋ช…์„ ์ถ”๊ฐ€ํ•  ํ•„์š”๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค (์Šคํƒ€์ผ๋ง ํด๋ž˜์Šค๋ช… 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>
  )
}

๋งˆ๋ฌด๋ฆฌ

์ด์ œ 1๊ฐœ์˜ ๊ฐ€๊ฒฉ์œผ๋กœ 2๊ฐœ์˜ ์›น์‚ฌ์ดํŠธ๋ฅผ ๊ฐ–๊ฒŒ ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ์ •๋ง ๋งŒ์กฑ์Šค๋Ÿฝ์Šต๋‹ˆ๋‹ค.

์ž, 7๊ฐ€์ง€ ์ฐจ์ด์ ์„ ์ฐพ์•„ ์ƒํ’ˆ์„ ๋ฐ›์•„ ๊ฐ€์„ธ์š”! ๐Ÿ˜…


๋‹ค์Œ์— ๋ฌด์—‡์ด ๋‚˜์˜ค๋Š”์ง€ ์•Œ์•„๋ณด์„ธ์š”

๋‰ด์Šค๋ ˆํ„ฐ๋ฅผ ๊ตฌ๋…ํ•˜์„ธ์š”: ์ƒˆ ๋ฒ„์ „์„ ์ถœ์‹œํ•˜๊ฑฐ๋‚˜, ์ƒˆ ํ”Œ๋Ÿฌ๊ทธ์ธ์„ ์„ ๋ณด์ด๊ฑฐ๋‚˜, ๊ณต์œ ํ•  ์†Œ์‹์ด ์žˆ์„ ๋•Œ ์•Œ๋ ค๋“œ๋ฆฝ๋‹ˆ๋‹ค.