Next.js 와 MDX 를 사용해서 SSG 블로그 만들기

블로그를 만들고 배포하는 과정을 설명합니다.

소개

Next.js 에서 MDX 를 사용하면 정적 사이트를 쉽게 만들고 배포할 수 있습니다. 이번 글에서는 Next.js와 MDX 를 사용하여 블로그를 만드는 과정을 자세히 설명하겠습니다.

Next.js 와 MDX 를 선택한 이유

Next.js는 React 기반의 프레임워크로, 정적 사이트 생성(SSG)과 서버 사이드 렌더링(SSR)을 지원합니다. Next.js를 선택한 이유는 다음과 같습니다:

  • 성능 최적화: Next.js는 정적 사이트 생성(SSG)을 통해 빠른 로딩 속도를 제공합니다.
  • SEO 최적화: 서버 사이드 렌더링(SSR)을 통해 검색 엔진 최적화(SEO)에 유리합니다.
  • 개발자 경험: Next.js는 파일 기반 라우팅, 자동 코드 분할, 핫 리로딩 등 개발자 경험을 향상시키는 기능을 제공합니다.

MDX는 Markdown과 JSX를 결합한 형식으로, Markdown의 간결함과 React 컴포넌트의 유연성을 동시에 활용할 수 있습니다. MDX를 선택한 이유는 다음과 같습니다:

  • 유연성: MDX를 사용하면 Markdown 문서 내에서 React 컴포넌트를 사용할 수 있어, 동적인 콘텐츠를 쉽게 작성할 수 있습니다. (Next 와 아주 잘 맞습니다.)
  • 재사용성: React 컴포넌트를 재사용하여 일관된 스타일과 기능을 유지할 수 있습니다.
  • 확장성: MDX는 플러그인을 통해 기능을 확장할 수 있어, 다양한 요구사항을 충족할 수 있습니다. (저는 코드 플러그인을 확장해서 사용하여 코드를 하이라이팅 했습니다.)

프로젝트 설정

Next.js 프로젝트를 생성하고, 필요한 패키지를 설치하는 초기 설정을 설명합니다.

Next.js 프로젝트 설정

Next 프로젝트를 생성하기 위해서 create-next-app 명령어를 사용합니다.

npx create-next-app my-blog

명령어를 실행하면 my-blog 폴더에 next 프로젝트가 생성됩니다.

필요한 패키지 설치

Next.js 프로젝트에 MDX를 통합하기 위해서 필요한 패키지를 설치합니다.

npm install @next/mdx @mdx-js/loader @mdx-js/react @types/mdx

초기 설정

Next.js 와 MDX 를 통합하기 위해서 next.config.js 파일을 수정합니다.
/next.config.js

import type { NextConfig } from "next";
import createMDX from "@next/mdx";
 
/** @type {import('next').NextConfig} */
const nextConfig: NextConfig = {
  pageExtensions: ["js", "jsx", "md", "mdx", "ts", "tsx"],
};
 
const withMDX = createMDX({
  // Add markdown plugins here, as desired
});
 
// Merge MDX config with Next.js config
export default withMDX(nextConfig);

위 설정을 통해서 MDX 파일을 Next.js 프로젝트에서 사용할 수 있게 됩니다. pagesExtensions 옵션은 확장자를 지정하여 지정된 옵션에 파일을 페이지로 인식될 수 있도록 합니다.

MDX 컴포넌트 생성

위에 설정은 MDX 파일을 Next 에서 page 로 인식할 수 있도록 합니다. MDX 에 적힌 컨텐츠를 react 의 컴포넌트로 표시하기 위해서 MDX 용 컴포넌트를 만들어야 합니다.
MDX 컴포넌트는 MDX 파일에 내용을 파싱하여 React 컴포넌트가 될 수 있도록 합니다.
/components/mdx.tsx

import { cn } from "@/utils";
import * as runtime from "react/jsx-runtime";
 
const components = {
  a: ({ className, ...props }: React.HTMLAttributes<HTMLAnchorElement>) => (
    <a target="_blank" rel="noopener" {...props} className={cn(className)} />
  ),
};
 
const useMDXComponent = (code: string) => {
  const fn = new Function(code);
  return fn({ ...runtime }).default;
};
 
interface Props {
  code: string;
}
 
export function Mdx({ code }: Props) {
  const Component = useMDXComponent(code);
 
  return (
    <div className="flex-1">
      <Component components={components} />
    </div>
  );
}

위 코드에서 components 를 사용해서 MDX 컨텐츠를 원하는 컴포넌트로 커스텀 할 수 있습니다.

블로그 컨텐츠 작성

블로그 컨텐츠를 작성하고 Next 앱을 빌드하여 정적 사이트를 생성합니다. 먼저 posts 폴더를 생성하여 블로그 컨텐츠를 작성합니다.
폴더 구조

|--/
|--/app
|--/next.config.js
|--/posts // posts 폴더 생성
|----/content.mdx // blog 컨텐츠 추가
|--/components
|----/mdx.tsx

Velite 를 사용한 정적 JSON 생성

MDX 파일을 사용해서 이제 페이지를 만들 수 있습니다. 하지만 블로그 컨텐츠는 썸네일 정보, 작성일, 설명 등 메타데이터가 보통은 필요합니다. Velite 는 MDX 를 파싱하여 JSON 생성하는데 MDX 파일 맨 위에 테이블을 추가하여 메타데이터를 넣을 수 있습니다.
메타 데이터를 사용한 마크다운 파일은 다음 같은 형식이 됩니다.
/posts/content.mdx

---
title: Next.js 와 MDX 를 사용해서 SSG 블로그 만들기
date: 2025-02-09
thumbnailUrl: /posts/thumbnail/Next-Mdx-Blog.webp
description: 블로그를 만들고 배포하는 과정을 설명합니다.
tags: Next@_@MDX@_@AWS@_@Github Actions@_@
tab: tech
---
 
... (생략 나머지 컨텐츠)

Velite 를 사용하여 정적 JSON 을 생성하기 위해 velite 를 설치합니다.

npm install velite

그런 다음, Velite 설정 파일을 생성합니다. 이름은 velite.config.js 로 합니다.
velite.config.js

import rehypePrettyCode from "rehype-pretty-code";
import { defineConfig, s } from "velite";
 
export default defineConfig({
  root: "posts",
  collections: {
    posts: {
      name: "Post", // collection type name
      pattern: "blog/*.mdx",
      schema: s
        .object({
          title: s.string().max(99),
          slug: s.path(),
          date: s.string(),
          description: s.string().max(199),
          thumbnailUrl: s.string().max(99),
          body: s.mdx(),
        })
        .transform((data) => {
          return {
            ...data,
            permalink: `/${data.slug}`,
            slug: data.slug.replaceAll("blog/", ""),
          };
        }),
    },
  },
  output: {
    data: ".velite",
    assets: "public/static",
    base: "/static/",
    name: "[name]-[hash:6].[ext]",
    clean: true,
  },
  mdx: {
    rehypePlugins: [[rehypePrettyCode, { theme: "nord" }]],
  },
});

schema 옵션을 사용하여 메타 데이터에 스키마를 설정합니다. 이후 npx velite 를 실행하면 설정 파일을 토대로 MDX 파일을 읽은 후 JSON 파일이 생성됩니다. 빌드 시점마다 velite 가 실행 될 수 있도록 하기 위해서 저는 pacakge.json 에 스크립트를 추가했습니다.
/package.json

{
  // ... 생략
  "script": {
    "predev": "velite",
    "dev": "next dev",
    "prebuild": "velite",
    "build": "next build"
  }
  // ... 생략
}

velite 가 실행되면 .velite 폴더에 posts.json 이 생성됩니다. posts.json 에 내용은 배열로 meta 데이터 형식과 react 컴포넌트를 생성할 수 있는 코드로 구성된 body 로 구성됩니다.
./velite/posts.json

[
  {
    "title": "Next.js 와 MDX 를 사용해서 SSG 블로그 만들기",
    "slug": "next-mdx-blog",
    "date": "2025-02-09",
    "description": "블로그를 만들고 배포하는 과정을 설명합니다.",
    "thumbnailUrl": "/posts/thumbnail/Next-Mdx-Blog.webp",
    "body": "...(react 컴포넌트가 될 수 있는 코드)...",
    "permalink": "/blog/next-mdx-blog"
  }
]

Page 생성

posts.json 을 사용하여 Next 페이지를 생성합니다. velite 로 생성된 json 을 활용해서 url endpoint 가 될 수 있도록 dynamic route 를 사용합니다.
src/app/blog/[slug]/page.tsx

export default async function BlogDetailPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const slug = (await params).slug;
 
  const post = getPost({ slug });
 
  return (
    <div className="py-6">
      <h1 className="text-brand-800 text-2xl sm:text-[3rem] font-bold tracking-tighter leading-tight pb-2 sm:pb-4">
        {post.title}
      </h1>
      <p className="text-xl sm:text-2xl font-semibold pb-2 sm:pb-4">
        {post.description}
      </p>
      <time className="text-slate-600 font-semibold">{post.date}</time>
      <article className="blog-post-container pt-4 sm:pt-6">
        <Mdx code={post.body} />
      </article>
    </div>
  );
}
 
export async function generateStaticParams() {
  const slugs = posts.map((post) => ({
    slug: post.slug,
  }));
 
  return slugs;
}
 
function getPost({ slug }: { slug: string }) {
  const post = posts.find((post) => post.slug === slug);
 
  if (!post) {
    throw new Error(`Post not found: ${slug}`);
  }
 
  return post;
}

배포

AWS 를 사용해서 배포했습니다. S3 버킷을 생성하여 정적 호스팅을 할 수 있도록 구성한 후 Cloud Front 에 Origin 이 S3 를 바라보도록 진행합니다.
S3 에 올려서 정적 사이트를 배포하기 위해서 html 빌드 결과를 만드는 작업이 필요합니다. output 에 결과가 html 파일이 될수 있또록 하려면 next.config.ts 파일을 수정해야 하는데 다음과 같습니다.
next.config.ts

import type { NextConfig } from "next";
import createMDX from "@next/mdx";
 
/** @type {import('next').NextConfig} */
const nextConfig: NextConfig = {
  images: {
    unoptimized: true, // export 모드에서 Next 에 Image 최적화가 동작하지 않기 때문에 unOptimized 를 true 로 설정합니다. ❗️
  },
  output: process.env.NODE_ENV === "development" ? "standalone" : "export", // production 환경일때 export 가 되게 합니다.❗️
  trailingSlash: true, // blog/index.html 이 되도록 합니다.❗️
};

먼저 output 모드를 export 로 설정하여 빌드 결과로 html 파일을 생성할수 있도록 합니다. 다음으로, export 모드에서는 따로 서버가 있는것이 아니기 때문에 이미지 최적화가 동작하지 않으므로 images.unoptimzed 를 true 로 설정합니다. trailingSlash 를 true 로 설정하여 page 이름에 폴더를 생성후 index.html 을 만들 도록 합니다. 예를 들어 이 옵션을 사용하지 않으면 빌드 결과가 /blog/index.html 이 아닌 /blog.html 이 됩니다. 보통 url 을 통해 웹 사이트를 접근할 때 /blog.html 이 아닌 /blog 로 접근하기 때문에 이 옵션을 키도록 합니다.

next build 로 build 후 결과를 s3 에 업로드 합니다.

정리

이런 방법을 사용하면 MDX 문서를 작성 하는것 만으로 Nexts 에 Page 를 구성할 수 있기 때문에 블로그 같은 정적 사이트를 쉽게 개발 할 수 있습니다. 정적 호스팅에 대한 비용도 AWS 기준으로 굉장히 저렴하기 때문에 비용 적인 측면에서도 이점이 있었습니다.
실제로 호스팅 하기 위해서 개발 외적인 부분에 신경 쓸 부분도 많았지만, 간단한 구성으로 MPA 와 SPA 에 장점을 모두 살릴 수 있기 때문에 유용하다고 생각됩니다.