[번역] Next.js 13과 리액트 서버 컴포넌트로 블로그 구축하기

Sonny·2023년 5월 21일
91

Article

목록 보기
11/21
post-thumbnail

원문 : https://maxleiter.com/blog/build-a-blog-with-nextjs-13

최근 몇 달 동안 Next.js 13 앱 라우터를 이용하여 이 웹사이트를 조작해보고 있었는데, 정말 좋은 경험이었습니다. 하지만 새로운 기능과 리액트 서버 컴포넌트를 사용하는 방법에 대해 혼란이 있는 것을 보아서 이 웹사이트를 구축하는 방법에 대한 개요를 작성했습니다.

리액트와 관련된 외면할 수 없는 사실을 말하자면, 블로그를 구축하기 위해 반드시 이런 고급 설정이 필요한 것은 아닙니다. HTML과 CSS가 더 나은 선택일 수도 있지만, 그렇게 하면 재미를 느끼지 못할 것이고 실험할 웹사이트를 만드는 것이 중요하다고 생각합니다.

(번역자 주: elephant in the room는 껄끄러운 문제, 외면할 수 없는 사실 등의 의미로 사용됩니다.)

먼저 이 글에서 어떤 내용들을 다룰지 설명드리겠습니다. 이 게시물에서 다루지 않을 내용은 다음과 같습니다.

  • Next.js 또는 리액트에 대한 문서 역할을 하지 않습니다.
  • 100% 코드 완성형 튜토리얼이 아닙니다. 부족한 부분은 직접 채워야 합니다.
    • 언제든지 이 웹사이트의 소스 코드를 참조할 수 있습니다.
    • 소스 코드 링크와 함께 파일 트리가 포함되어 있습니다.

다룰 내용은 다음과 같습니다.

  • 리액트 서버 컴포넌트와 앱 라우터와 관련된 실제 예제를 보여드립니다.
  • 뛰어난 SEO와 성능을 갖춘 Next.js 13과 리액트 서버 컴포넌트로 나만의 블로그를 제작하는 방법을 안내합니다.
    • 동적 렌더링을 선택할 수 있지만, 이 글에 제시된 모든 내용이 완전히 정적인, 자바스크립트가 없는 사이트가 될 수 있기 때문에 정적 우선이라고 말합니다.
  • MDXnext-mdx-remote/rsc를 사용하여 마크다운으로 작성하는 방법을 시연합니다.
    • 서버 측 구문 강조 표시에는 Bright를 사용하겠습니다.
  • 여러분만의 실험과 탐구를 위한 출발점으로 삼으세요.

이제 설명이 끝났습니다.

목차

프로젝트 설정

먼저 새 Next.js 프로젝트를 만들어야 합니다. npx-create-next-app로 설정 마법사를 시작할 수 있습니다.

npx create-next-app --experimental-app

NOTE
기존 프로젝트를 마이그레이션하려면 Next.js 설치 문서를 참조하세요.

설정을 완료하려면 y (또는 n을 누르는 것도 괜찮습니다.)를 몇 번 누르세요.

(번역자 주: I'm not your boss는 상대방을 지시하는 것이 아니라 단지 의견을 제시하고 있다는 것을 강조하는 농담의 요소로 사용됩니다.)

파일 구조

앱 라우터 애플리케이션을 구조화하는 가장 쉬운 방법은 하향식 접근 방식입니다. 일반적인 경로 구조를 생각한 다음, 가장 높은 수준의 '스코프'(layout)부터 시작하여 아래로 내려갑니다.

이 경우, home 페이지가 있습니다. projects 페이지about 페이지도 추가해 보겠습니다. 블로그는 다음 섹션에서 다루겠습니다.

일반적으로 page는 다음과 같이 표시됩니다.

// page.tsx
export default function Page() {
  return <>Your content here</>
}

제 경우에는 콘텐츠를 제외한 세 페이지가 모두 동일하게 보이므로 레이아웃을 공유할 수 있는 강력한 후보인 것 같습니다.

레이아웃은 다음과 같이 표시됩니다.

// layout.tsx
export default function Layout({ children }: PropsWithChildren) {
  return (
    <>
      // 여기에 레이아웃 컨텐츠를 작성하세요.
      {children}
      // 혹은 여기에 작성하세요.
    </>
  )
}

이제 이 모든 것을 고려해서 app/layout.tsx, app/page.tsx, app/projects/page.tsx, app/about/page.tsx 의 네 개의 파일을 만들기로 결정했습니다. 레이아웃은 모든 페이지에 적용되며 각 페이지 파일에는 고유한 콘텐츠가 포함됩니다.

이 접근 방식에서 한 가지 작은 문제가 발생했습니다. home 페이지에는 아이콘이 필요하지 않지만 다른 페이지에는 아이콘이 필요하다는 것입니다. home 페이지에 아이콘을 포함시키는 것은 의미가 없지만 다른 모든 페이지에는 아이콘이 있어야 하므로 루트 레이아웃에서 제외하고 다른 페이지에만 레이아웃이 적용되도록 자체 중첩 레이아웃을 가진 라우트 그룹을 만들겠습니다.

먼저 app/(subpages)/components 디렉토리를 만들고 하위 페이지에 대해서만 퀵 헤더를 만들어 보겠습니다.

// app/(subpages)components/header.tsx
import Link from 'next/link'
import { HomeIcon } from 'react-feather'

export default function Header() {
  return (
    <header>
      <Link href="/">
        <HomeIcon />
      </Link>
      // 테마 변환기와 같은 클라이언트 컴포넌트를 여기에 추가하려면 다음과 같이 표시하세요.
      // 컴포넌트를 "use client"로 설정하고 헤더의 대부분을 RSC로 남겨둡니다.
    </header>
  )
}

그리고 app/(subpages)/layout.tsx에서 사용하세요.

// app/(subpages)/layout.tsx
import Header from './components/header'

export default function SubLayout({ children }) {
  return (
    <>
      <Header />
      {children}
    </>
  )
}

헤더가 제자리에 배치되었으므로 이제 하위 페이지와 블로그 게시물에 대한 중첩 레이아웃이 생겼습니다.

이제 파일과 디렉토리를 만들어 보겠습니다.

NOTE
파일 트리에서 아무 파일이나 클릭하면 깃허브에서 소스 코드를 볼 수 있습니다.

app

블로그에는 게시물이 필요합니다.

동적 세그먼트를 사용한 라우팅

이와 같은 블로그 게시물의 경우, /blog/[slug]는 하나의 블로그 게시물을 표시하는 페이지가 되어야 하며, 다른 게시물과 연결되는 고유한 푸터(footer)가 있어야 합니다. 푸터는 /blog/[slug] 레이아웃 안에 있어야 할 것 같습니다. URL의 [slug]동적 세그먼트라고 합니다.

blog

그렇다면 블로그 게시물에서 마크다운 파일을 어떻게 렌더링할까요?

마크다운 가져오기 및 렌더링

Next.js 공식 문서에는 모든 페이지에 MDX를 사용하기 위한 훌륭한 가이드가 있습니다. 하지만 CMS와 같은 원격 소스의 콘텐츠를 렌더링하고 싶을 때도 있습니다. 제 경우에는 특정한 이유로 마크다운을 Next.js 프로젝트와 분리하고 싶었기 때문에 next-mdx-remote와 실험적인 리액트 서버 컴포넌트를 사용할 것입니다.

자신의 프로젝트에 적용하려는 경우에도 코드의 대부분은 동일하므로 코드 스니펫을 따라하기만 하면 됩니다.

게시물 가져오기

게시물을 렌더링하기 전에 어딘가에서 게시물을 가져와야 합니다. 이를 수행하는 방법에는 여러 가지가 있습니다. 다음은 파일 시스템에서 게시물을 불러오는 방법으로, 단순하지만 잘 작동하는 버전입니다.

import matter from 'gray-matter'
import path from 'path'
import type { Post } from './types'
import fs from 'fs/promises'
import { cache } from 'react'

// `cache` 는 요청의 수명 기간 동안 함수를 캐시할 수 있는 리액트 18의 기능입니다.
// 즉, 페이지를 렌더링할 때 여러 번 호출할 수 있지만 페이지 빌드 당 한 번만 getPosts()가 호출됩니다.
export const getPosts = cache(async () => {
  const posts = await fs.readdir('./posts/')

  return Promise.all(
    posts
      .filter((file) => path.extname(file) === '.mdx')
      .map(async (file) => {
        const filePath = `./posts/${file}`
        const postContent = await fs.readFile(filePath, 'utf8')
        const { data, content } = matter(postContent)

        if (data.published === false) {
          return null
        }

        return { ...data, body: content } as Post
      })
  )
})

export async function getPost(slug: string) {
  const posts = await getPosts()
  return posts.find((post) => post.slug === slug)
}

export default getPosts

// 사용 방법
const posts = await getPosts()
const post = await getPost('my-post')

getPosts는 캐시되므로 네트워크 워터폴에 대한 걱정 없이 레이아웃 트리에서 getPost를 여러 번 호출할 수 있습니다.

게시물 렌더링하기

이제 게시물이 생겼으니 렌더링할 수 있습니다.

먼저, remark 및 rehype 플러그인을 사용하여 MDX를 설정해야 합니다. 모든 플러그인은 선택 사항이지만 제가 사용하는 플러그인을 포함했습니다.

// app/(subpages)/blog/[slug]/components/post-body.tsx
import { MDXRemote } from 'next-mdx-remote/rsc'

import remarkGfm from 'remark-gfm'
import rehypeSlug from 'rehype-slug'
import rehypeAutolinkHeadings from 'rehype-autolink-headings'
import remarkA11yEmoji from '@fec/remark-a11y-emoji'
import remarkToc from 'remark-toc'
import { mdxComponents } from './markdown-components'

export function PostBody({ children }: { children: string }) {
  return (
    // @ts-expect-error RSC
    <MDXRemote
      source={children}
      options={{
        mdxOptions: {
          remarkPlugins: [
            // 깃허브 Flavored 마크다운 지원 추가
            remarkGfm,
            // 이모티콘 접근성 향상
            remarkA11yEmoji,
            // 제목을 기반으로 목차를 생성합니다.
            remarkToc,
          ],
          // 함께 작동하여 ID를 추가하고 제목을 연결합니다.
          rehypePlugins: [rehypeSlug, rehypeAutolinkHeadings],
        },
      }}
      components={mdxComponents}
    />
  )
}

components={mdxComponents} prop을 볼 수 있습니다. 여기에 마크다운 파일에 사용할 커스텀 컴포넌트를 전달합니다. Next.js와 함께 사용하려면 클라이언트 측 라우팅 및 이미지 최적화를 선택하기 위해 next/linknext/image 와 같은 공식 컴포넌트를 사용하고 싶을 것입니다. 이 글에서 파일 트리와 같은 컴포넌트를 정의한 곳이기도 합니다.

// app/(subpages)/blog/[slug]/components/markdown-components.tsx
import Link from 'next/link'
import Image from 'next/image'

export const mdxComponents: MDXComponents = {
  a: ({ children, ...props }) => {
    return (
      <Link {...props} href={props.href || ''}>
        {children}
      </Link>
    )
  },
  img: ({ children, props }) => {
    // 이미지의 너비와 높이를 얻으려면 여기에서 몇 가지 작업을 수행해야 합니다.
    // 제 해결책에 대한 자세한 내용은 아래를 참조하세요.
    return <Image {...props} />
  },
  // 마크다운에 사용하려는 다른 컴포넌트
}
이미지의 너비와 높이를 얻는 방법

이 작업을 수행하는 더 좋은 방법(next/image의 sizes prop을 사용하는 방법)이 있을 수 있지만, 저는 이미지 URL에 원하는 이미지 너비와 높이를 쿼리 매개변수로 추가합니다. 이렇게 하면 URL에서 너비와 높이를 가져와 next/image로 전달할 수 있습니다.

// app/(subpages)/blog/[slug]/components/mdx-image.tsx
import NextImage from 'next/image'

export function MDXImage({
  src,
  alt,
}: React.DetailedHTMLProps<
  React.ImgHTMLAttributes<HTMLImageElement>,
  HTMLImageElement
> & {
  src: string
  alt: string
}) {
  let widthFromSrc, heightFromSrc
  const url = new URL(src, 'https://maxleiter.com')
  const widthParam = url.searchParams.get('w') || url.searchParams.get('width')
  const heightParam =
    url.searchParams.get('h') || url.searchParams.get('height')
  if (widthParam) {
    widthFromSrc = parseInt(widthParam)
  }
  if (heightParam) {
    heightFromSrc = parseInt(heightParam)
  }

  const imageProps = {
    src,
    alt,
    // 원하는 대로 조정하세요.
    height: heightFromSrc || 450,
    width: widthFromSrc || 550,
  }

  return <NextImage {...imageProps} />
}
// 마크다운 파일에서는 다음과 같이 명시합니다.
![alt text](/image.png?width=500&height=400)

Bright로 구문 강조 표시하기

Bright는 code-hike의 새로운 RSC 우선 구문 하이라이터입니다. 서버에서 강조 표시를 수행하므로 필요한 스타일과 마크업만 클라이언트로 전송됩니다. 또한 줄 번호, 강조 표시 등 사용자가 원하는 모든 확장 기능을 최고 수준으로 지원합니다.

bright 패키지를 설치하고 다음과 같이 MDX 컴포넌트에 사용합니다.

import { Code } from 'bright'
export const mdxComponents: MDXComponents = {
  // 이전의 `a` 및 `img` 태그는 그대로 유지되어야 합니다.
  pre: Code,
}

이것이 훌륭한 구문 강조 표시를 위해 필요한 전부입니다.

이제 MDX 설정이 완료되고 컴포넌트와 함께 사용할 수 있으므로 게시물을 렌더링할 수 있습니다.

먼저, 앞서 만든 getPost 함수와 PostBody 컴포넌트를 가져와 보겠습니다.

// app/(subpages)/blog/[slug]/page.tsx
import getPosts from '@lib/get-posts'
import { PostBody } from '@mdx/post-body'

이제 컴포넌트를 렌더링하면 됩니다.

import getPosts, { getPost } from '@lib/get-posts'
import { PostBody } from '@mdx/post-body'
import { notFound } from 'next/navigation'

export default async function PostPage({
  params,
}: {
  params: {
    slug: string
  }
}) {
  const post = await getPost(params.slug)
  // notFound는 Next.js 유틸리티입니다.
  if (!post) return notFound()
  // 게시물 콘텐츠를 MDX로 전달
  return <PostBody>{post?.body}</PostBody>
}

이제 게시물을 렌더링할 수 있게 되었습니다. 정말 멋지네요.

선택적으로 페이지에 generateStaticParams를 추가하여 빌드 시점에 모든 게시물을 빌드하도록 선택할 수 있습니다.

export async function generateStaticParams() {
  const posts = await getPosts()
  // 페이지를 미리 렌더링할 매개변수는 다음과 같습니다.
  // 이 기능이 없으면 페이지가 런타임에 렌더링됩니다.
  return posts.map((post) => ({ params: { slug: post.slug } }))
}

NOTE
getPostgetPosts()를 호출하는 것이 나쁘다고 생각하신다면, getPostscache로 랩핑했다는 점을 기억하세요. getPostgetPosts를 호출할 뿐이므로 파일 시스템 또는 게시물을 가져오는 곳에 불필요한 요청을 하지 않습니다.

SEO

새로운 Metadata API는 환상적이지만, 현재 진행 중인 주요 작업이기도 합니다. 문서에서 최신 업데이트를 확인해 주세요.

Metadata API

새로운 Metadata API에는 훌륭한 문서가 있으므로 여기서는 너무 자세히 설명하지 않겠습니다. 저는 대부분의 레이아웃을 루트 레이아웃에서 정의하고 필요에 따라 최종 페이지에서 재정의합니다.

루트 레이아웃의 메타데이터는 다음과 같습니다.

// app/layout.tsx
export const metadata = {
  title: {
    template: '%s | Max Leiter',
    default: 'Max Leiter',
  },
  description: 'Full-stack developer.',
  themeColor: [
    { media: '(prefers-color-scheme: light)', color: '#f5f5f5' },
    { media: '(prefers-color-scheme: dark)', color: '#000' },
  ],
  openGraph: {
    title: 'Max Leiter',
    description: 'Full-stack developer.',
    url: 'https://maxleiter.com',
    siteName: "Max Leiter's site",
    locale: 'en_US',
    type: 'website',
    // 자체 엔드포인트를 사용하려면 https://vercel.com/docs/concepts/functions/edge-functions/og-image-generation 을 참조하세요.
    // 공식 `app/` 솔루션이 곧 출시될 예정이니 참고하세요.
    images: [
      {
        url: `https://maxleiter.com/api/og?title=${encodeURIComponent(
          "Max Leiter's site"
        )}`,
        width: 1200,
        height: 630,
        alt: '',
      },
    ],
  },
  twitter: {
    title: 'Max Leiter',
    card: 'summary_large_image',
    creator: '@max_leiter',
  },
  icons: {
    shortcut: 'https://maxleiter.com/favicons/favicon.ico',
  },
  alternates: {
    types: {
      // 자세한 내용은 RSS 피드 섹션을 참조하세요.
      'application/rss+xml': 'https://maxleiter.com/feed.xml',
    },
  },
}

페이지 메타데이터는 다음과 같이 표시될 수 있습니다.

// app/(subpages)/about/page.tsx
export const metadata = {
  title: 'About',
  alternates: {
    canonical: 'https://maxleiter.com/about',
  },
}

사이트맵 지원 (sitemap.js)

자세한 내용은 @leeerob의 발표 트윗을 참조하세요.

// app/sitemap.ts
import { getPosts } from './lib/get-posts'

export default async function sitemap() {
  const posts = await getPosts()
  const blogs = posts.map((post) => ({
    url: `https://maxleiter.com/blog/${post.slug}`,
    lastModified: new Date(post.lastModified).toISOString().split('T')[0],
  }))

  const routes = ['', '/about', '/blog', '/projects'].map((route) => ({
    url: `https://maxleiter.com${route}`,
    lastModified: new Date().toISOString().split('T')[0],
  }))

  return [...routes, ...blogs]
}

RSS 피드 생성

RSS 피드에 대한 공식 해결책을 기다리는 동안 저에게 잘 맞는 사용자 정의 해결책을 만들었습니다. marked 라이브러리를 사용하여 마크다운 파일을 파싱한 다음 rss 라이브러리를 사용하여 RSS 피드를 생성합니다. 즉, MDX용 JSX 컴포넌트가 RSS 피드로 전달되므로 렌더링하지 않을 때에도 컴포넌트가 가독성 있게 유지되도록 노력합니다.

// scripts/rss.ts
import fs from 'fs'
import RSS from 'rss'
import path from 'path'
import { marked } from 'marked'
import matter from 'gray-matter'

const posts = fs
  .readdirSync(path.resolve(__dirname, '../posts/'))
  .filter(
    (file) => path.extname(file) === '.md' || path.extname(file) === '.mdx'
  )
  .map((file) => {
    const postContent = fs.readFileSync(`./posts/${file}`, 'utf8')
    const { data, content }: { data: any; content: string } =
      matter(postContent)
    return { ...data, body: content }
  })
  .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())

const renderer = new marked.Renderer()

renderer.link = (href, _, text) =>
  `<a href="${href}" target="_blank" rel="noopener noreferrer">${text}</a>`

marked.setOptions({
  gfm: true,
  breaks: true,
  renderer,
})

const renderPost = (md: string) => marked.parse(md)

const main = () => {
  const feed = new RSS({
    title: 'Max Leiter',
    site_url: 'https://maxleiter.com',
    feed_url: 'https://maxleiter.com/feed.xml',
    // image_url: 'https://maxleiter.com/og.png',
    language: 'en',
    description: "Max Leiter's blog",
  })

  posts.forEach((post) => {
    const url = `https://maxleiter.com/blog/${post.slug}`

    feed.item({
      title: post.title,
      description: renderPost(post.body),
      date: new Date(post?.date),
      author: 'Max Leiter',
      url,
      guid: url,
    })
  })

  const rss = feed.xml({ indent: true })
  fs.writeFileSync(path.join(__dirname, '../public/feed.xml'), rss)
}

main()

배포

저는 배포에 Vercel을 사용하지만(개인적인 기호가 반영되었을 수 있지만), 정적 내보내기를 지원하므로 이와 같은 설정으로 원하는 정적 사이트 공급자를 사용할 수 있습니다.

마무리

이 게시물이 도움이 되었기를 바랍니다. 참고로 이 사이트의 소스 코드는 여기에서 찾을 수 있습니다. 어떤 것이든 저를 부정적으로 생각하지 마시고 마음에 드는 것이 있으면 언제든지 칭찬해 주세요.

profile
FrontEnd Developer

5개의 댓글

comment-user-thumbnail
2023년 5월 22일

항상 번역 감사드립니다ㅎㅎ 저 블로그 엄청나게 빠른게 느껴집니다.. 13.2에 나온 mdxRs 이거까지 반영하면 더빨라지겠죠?ㄷㄷ

답글 달기
comment-user-thumbnail
2023년 5월 26일

좋은 글 감사합니다 ^^

답글 달기
comment-user-thumbnail
2023년 6월 12일

I have tried to access https://octordle3.com many times, can you help me where is the problem?

답글 달기
comment-user-thumbnail
2023년 6월 23일

I've repeatedly attempted to access it could you please tell me what's wrong https://thewikiinc.com/?

답글 달기
comment-user-thumbnail
2023년 7월 15일

Thank you to everyone for their efforts, and thanks to the author of the article. https://busd-store.com

답글 달기