웹 프로그래밍

MDX를 활용한 정적 블로그 구축

nextjs
Published

December 8, 2025

Abstract

이 실습 문제는 Next.js를 사용하여 MDX를 활용한 정적 블로그를 단계별로 구성하는 실습 문제입니다. 완성된 페이지는 practice-nextjs-mdx 를 참고하세요.

0. 정적 블로그

정적 블로그는 개인의 전문성 증명과 꾸준한 자기 계발의 기록 공간으로서, 팀 합류에 중요한 역할을 합니다. 개발자가 자신이 공부한 내용, 프로젝트 경험, 문제 해결 과정을 글로 정리하여 보여주면, 팀 합류를 담당하는 사람이 지원자의 깊은 이해도와 지속적인 성장 의지를 직접 확인할 수 있습니다.

무엇보다도 Github Pages를 활용하는 정적 블로그는 팀 합류 시 큰 다음과 같은 이점이 있습니다.

  • 무료 호스팅으로 비용 부담 없이 나만의 포트폴리오 및 기술 블로그를 운영할 수 있음
  • Git과 연동되어 버전관리 및 코드 배포 경험을 동시에 보여줄 수 있어 기술 역량을 간접적으로 어필하는 데 유리
  • HTML, CSS, JavaScript 등을 활용해 블로그를 자유롭게 커스터마이즈할 수 있어서 개발자로서의 실무 능력도 시각적으로 표현 가능
  • 외부에 손쉽게 URL 링크를 공유하며 실무나 이직 시 자신의 역량을 간편하게 전달할 수 있음

정적 블로그를 잘 운영하면 단순히 이력서나 면접 답변보다 강력한 개인 브랜딩 도구가 되며, 전문 분야에서 차별화된 경쟁력을 갖추는 데 도움을 줍니다. 취업 준비 과정에서 자신의 공부, 프로젝트, 경험을 꾸준히 기록해나가는 것이 취업 성공률을 높이는데 특히 효과적이라는 점도 알려져 있습니다12.

따라서 정적 블로그와 Github Pages는 팀 합류시 자신의 전문성과 꾸준함을 증명하고, 문제 정의 능력과 해결 능력을 시각화하여 효과적으로 제안할 수 있는 유용한 도구입니다. 개발직군에서는 직접 만든 Github Pages 블로그가 기술력을 보여줄 수 있는 좋은 포트폴리오로 작용할 수 있습니다3.

실습 개요

  • Next.js 16과 MDX를 활용한 정적 블로그 구축
  • GitHub Pages를 통한 무료 호스팅

주요 특징

  • 최신 Next.js의 주요 기능을 최대한 활용
  • 정적 사이트 생성(SSG)
    • 모든 페이지를 빌드 타임에 한 번에 생성
    • 서버 부하 없이 빠르고 최적의 퍼포먼스 제공
  • MDX 지원
    • 마크다운 + React 컴포넌트 삽입 가능
    • 실습 코드, 알림박스, 차트 등 다양한 콘텐츠 구현
  • 반응형 디자인
    • 모바일~데스크탑까지 최적의 읽기 경험 제공
  • SEO(검색엔진최적화) 극대화
    • 메타데이터 자동 설정
    • 검색엔진 친화적 구조로 상위 노출 용이
  • 빠른 로딩 속도
    • 정적 렌더링, 불필요한 서버 요청 없음
    • 방문자가 쾌적하게 자료 열람 가능

기술 스택

  • React 기반 프레임워크 Next.js(16버전, App Router 구조)로 정적 블로그 개발
  • 프로젝트 구조/라우팅/SSG(정적생성)/SSR 등 현대적 웹 개발 기능을 간편하게 제공
  • 주요 라이브러리 및 역할
    • @next/mdx, next-mdx-remote: Next.js에서 MDX(Markdown+JSX) 자유롭게 활용
      • 마크다운 안에 React 컴포넌트 동적 삽입 가능
    • remark: 마크다운 \(\to\) JS 객체 파싱
    • remark-gfm: 표, 체크박스 등 GitHub Flavored Markdown(GFM) 문법 지원
    • remark-math, rehype-katex:
      • 마크다운의 수학 수식(Markdown Math) 파싱
      • KaTeX로 HTML 수식 렌더링
    • rehype-highlight: 코드블록 구문 하이라이트
    • gray-matter: 포스트 상단 메타데이터(제목, 태그, 날짜 등) 파싱
    • github-markdown-css: 최종 마크다운 렌더링 시 GitHub 스타일 적용
  • 완성된 사이트는 GitHub Pages로 무료 퍼블리싱
  • GitHub Actions로 자동 빌드,배포 연동(지속적 업데이트)

프로젝트 구조

practice-nextjs-mdx/
├── app/                         # Next.js App Router
│   ├── layout.tsx               # 루트 레이아웃
│   ├── page.tsx                 # 홈 페이지
│   ├── blog/                    # 블로그 섹션
│   │   ├── page.tsx             # 블로그 목록 페이지
│   │   └── [slug]/              # 동적 라우트
│       └── page.tsx             # 개별 포스트 페이지
│   └── globals.css              # 전역 스타일
├── content/                     # MDX 파일 저장소
│   └── posts/                   # 블로그 포스트
│       ├── first-post.mdx
│       ├── second-post.mdx
│       └── ...
├── components/                  # React 컴포넌트
│   ├── BlogPost.tsx             # 포스트 레이아웃
│   ├── PostCard.tsx             # 포스트 카드
│   ├── Navigation.tsx           # 네비게이션
│   └── Footer.tsx               # 푸터
├── lib/                         # 유틸리티 함수
│   ├── mdx.ts                   # MDX 처리 함수
│   └── posts.ts                 # 포스트 관련 함수
├── public/                      # 정적 파일
│   ├── images/                  # 이미지 파일
│   └── favicon.ico
├── next.config.ts               # Next.js 설정
├── mdx-components.tsx           # MDX 컴포넌트 매핑
├── package.json
├── tsconfig.json
└── .github/
    └── workflows/
        └── nextjs.yml           # GitHub Actions 워크플로우

주요 기능

  • MDX 파일로 포스트 작성
    • Frontmatter를 통한 메타데이터 관리 (제목, 날짜, 태그 등)
  • 자동 포스트 목록 생성
  • 마크다운 문법 지원 (GitHub Flavored Markdown)
    • 코드 하이라이팅 (rehype-highlight)
    • 수학 수식 렌더링 (KaTeX)
    • GitHub 스타일 마크다운 CSS
  • React 컴포넌트 삽입 가능

부가적인 기능

  • 태그/카테고리 필터링 (도전과제)
  • 다크 모드 지원 (도전과제)
  • 검색 기능 (도전과제)
  • 페이지네이션 (도전과제)

1. 프로젝트 구현 설계

  1. 포스트 읽기 함수 구현 (lib/posts.ts)
  • MDX 파일 읽기
  • Frontmatter 파싱
  • 포스트 목록 생성
  • 포스트 정렬 (날짜 기준)
  1. 블로그 목록 페이지 (app/blog/page.tsx)
  • 모든 포스트 목록 표시
  • 포스트 카드 컴포넌트 사용
  1. 개별 포스트 페이지 (app/blog/[slug]/page.tsx)
  • 동적 라우팅으로 포스트 표시
  • MDX 콘텐츠 렌더링
  1. 레이아웃 컴포넌트 (components/BlogPost.tsx)
  • 포스트 레이아웃 구성
  • 제목, 날짜, 태그 표시

2. 상세 구현

2-1단계: 프로젝트 초기화 및 환경 설정

npx create-next-app@latest practice-nextjs-mdx --yes

2-2단계: 필수 패키지 설치

npm install @next/mdx @mdx-js/loader @mdx-js/react remark remark-gfm rehype-highlight gray-matter next-mdx-remote remark-math rehype-katex github-markdown-css
  • next-mdx-remote: App Router에서 동적 MDX 렌더링을 위해 필요
  • remark-math, rehype-katex: 수학 수식 지원
  • github-markdown-css: GitHub 스타일 마크다운 CSS
  • @next/mdx: Next.js용 MDX 통합 플러그인
  • next-mdx-remote: 동적 MDX 렌더링 지원
  • @mdx-js/loader: MDX 에서 JSX로 변환하는 Webpack 로더
  • @mdx-js/react: MDX용 React 컴포넌트 매핑
  • remark: 마크다운 파서(확장용)
  • remark-gfm: GFM(GitHub 마크다운) 지원
  • remark-math: 마크다운 수식 파서
  • rehype-highlight: 코드 블록 하이라이트
  • rehype-katex: 수식(KaTeX) HTML 렌더링
  • github-markdown-css: GitHub 마크다운 스타일 CSS

2-3단계: 기본 설정 (MDX & Next.js)

MDX를 지원하도록 Next.js 설정을 변경합니다. withMDX 관련 설정이 중요합니다. Next.js 프로젝트 내에서 MDX 기반의 블로그, 문서, 위키 등 다양한 정적 콘텐츠를 React 컴포넌트로 쉽게 취급할 수 있음, 블로그와 같이 마크다운과 리액트 컴포넌트를 혼합해 콘텐츠를 작성해야 할 때 필수적인 설정입니다.

  • withMDX는 Next.js에서 MDX 파일을 페이지로 사용할 수 있게 해주려면, MDX 파일을 일반적인 React 컴포넌트처럼 인식하고 처리할 수 있어야 함, Next.js의 설정 객체(next.config.js 또는 next.config.ts)에 MDX 관련 처리를 통합하는 함수
  • .mdx 파일을 import 하거나 페이지로 직접 쓸 수 있게 Next.js의 파일 확장자를 확장하고, 빌드 과정에서 MDX를 React 컴포넌트로 변환하는 로더를 자동으로 설정하며, 필요한 경우 remark/rehype 등 다양한 플러그인으로 MDX의 기능을 확장할 수 있게 해줌
// next.config.ts
import createMDX from '@next/mdx'
import type { NextConfig } from 'next'

const withMDX = createMDX({
  options: {
    // Plugins are handled in MDXRemote for dynamic content
    // remarkPlugins: [],
    // rehypePlugins: [],
  },
})

const nextConfig: NextConfig = {
  pageExtensions: ['js', 'jsx', 'md', 'mdx', 'ts', 'tsx'],
}

export default withMDX(nextConfig)

2-4단계: mdx-components.tsx 생성

기본 컴포넌트 매핑은 필요에 따라 추가할 수 있습니다. 현재는 next-mdx-remote를 사용하여 동적으로 렌더링하므로 기본 매핑만 사용합니다. MDX(Markdown + JSX) 파일에서 사용되는 기본 HTML 요소(h1, h2, p 등)를 확장하거나 커스터마이징하기 위해 사용됩니다. Next.js에서 MDX 파일을 렌더링할 때 각각의 태그가 어떻게 보일지 사용자 정의 컴포넌트로 대체할 수 있는데, 이 파일에서는 h1, h2, p 태그를 Tailwind CSS 등으로 스타일링한 React 컴포넌트로 매핑합니다. 이를 통해 블로그 글 전체에 일관된 스타일과 UI/UX를 적용할 수 있고, 필요하다면 코드블록, 이미지 등 다양한 HTML 태그에 대해서도 자유롭게 컴포넌트를 확장하여 사용할 수 있습니다. 현재는 github-markdown-css 스타일을 사용하기 때문에 별도의 설정은 하지 않았습니다.

// mdx-components.tsx (루트 디렉토리)
import type { MDXComponents } from 'mdx/types'

export function useMDXComponents(components: MDXComponents): MDXComponents {
  return components;
}

2-5단계: 디렉토리 구조 및 유틸리티 구현

블로그 포스트를 저장할 공간과 이를 읽어올 함수를 작성합니다.

  1. 디렉토리 생성
  • content/posts: MDX 파일 저장소
  • lib: 유틸리티 함수
  • components: React 컴포넌트
  1. lib/posts.ts 구현
  • MDX 파일을 읽고 파싱하는 핵심 로직
// lib/posts.ts
import fs from 'fs'
import path from 'path'
import matter from 'gray-matter'

const postsDirectory = path.join(process.cwd(), 'content/posts')

export interface Post {
  slug: string
  title: string
  date: string
  description?: string
  tags?: string[]
  content: string
}

export function getSortedPostsData(): Omit<Post, 'content'>[] {
  if (!fs.existsSync(postsDirectory)) return []
  
  const fileNames = fs.readdirSync(postsDirectory)
  const allPostsData = fileNames.map((fileName) => {
    const slug = fileName.replace(/\.mdx$/, '')
    const fullPath = path.join(postsDirectory, fileName)
    const fileContents = fs.readFileSync(fullPath, 'utf8')
    const { data } = matter(fileContents)

    return {
      slug,
      ...(data as any),
    }
  })

  return allPostsData.sort((a, b) => (a.date < b.date ? 1 : -1))
}

export function getPostData(slug: string): Post {
  const fullPath = path.join(postsDirectory, `${slug}.mdx`)
  const fileContents = fs.readFileSync(fullPath, 'utf8')
  const { data, content } = matter(fileContents)

  return {
    slug,
    content,
    ...(data as any),
  }
}

2-6단계: components/BlogPost.tsx 생성

// components/BlogPost.tsx
import React from 'react'

interface BlogPostProps {
  title: string
  date: string
  children: React.ReactNode
}

export default function BlogPost({ title, date, children }: BlogPostProps) {
  return (
    <article className="max-w-2xl mx-auto py-8 px-4">
      <header className="mb-8">
        <h1 className="text-3xl font-bold mb-2">{title}</h1>
        <time className="text-gray-500">{date}</time>
      </header>
      <div className="prose dark:prose-invert">
        {children}
      </div>
    </article>
  )
}

2-7단계: app/blog/page.tsx

전체 글 목록과 개별 글 상세 페이지를 구현합니다.

import Link from 'next/link'
import { getSortedPostsData } from '@/lib/posts'

export default function BlogIndex() {
  const allPostsData = getSortedPostsData()
  return (
    <div className="max-w-2xl mx-auto py-8 px-4">
      <h1 className="text-4xl font-bold mb-8">Blog</h1>
      <ul className="space-y-4">
        {allPostsData.map(({ slug, date, title, description }) => (
          <li key={slug} className="border p-4 rounded-lg hover:shadow-md transition-shadow">
            <Link href={`/blog/${slug}`}>
              <h2 className="text-2xl font-bold">{title}</h2>
              <div className="text-gray-500 text-sm mb-2">{date}</div>
              {description && <p className="text-gray-700">{description}</p>}
            </Link>
          </li>
        ))}
      </ul>
    </div>
  )
}

2-8단계: app/blog/[slug]/page.tsx

//  `app/blog/[slug]/page.tsx`
import { getPostData, getSortedPostsData } from '@/lib/posts'
import BlogPost from '@/components/BlogPost'
import { MDXRemote } from 'next-mdx-remote/rsc'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import rehypeHighlight from 'rehype-highlight'
import rehypeKatex from 'rehype-katex'

export async function generateStaticParams() {
  const posts = getSortedPostsData()
  return posts.map((post) => ({
    slug: post.slug,
  }))
}

export default async function Post({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params
  const postData = getPostData(slug)

  return (
    <BlogPost title={postData.title} date={postData.date}>
      <div className="markdown-body">
        <MDXRemote
          source={postData.content}
          options={{
            mdxOptions: {
              remarkPlugins: [remarkGfm, remarkMath],
              rehypePlugins: [rehypeHighlight, rehypeKatex],
            },
          }}
        />
      </div>
    </BlogPost>
  )
}

2-9단계: app/page.tsx

import Link from 'next/link'
import { getSortedPostsData } from '@/lib/posts'

export default function Home() {
  const allPostsData = getSortedPostsData()
  const recentPosts = allPostsData.slice(0, 5)

  return (
    <div className="flex min-h-screen flex-col items-center justify-center p-24">
      <h1 className="text-4xl font-bold mb-8">Welcome to My Static Blog</h1>
      
      <div className="w-full max-w-2xl">
        <h2 className="text-2xl font-semibold mb-6">Recent Posts</h2>
        {recentPosts.length > 0 ? (
          <ul className="space-y-4">
            {recentPosts.map(({ slug, date, title, description }) => (
              <li key={slug} className="border p-4 rounded-lg hover:shadow-md transition-shadow bg-white dark:bg-gray-800 dark:border-gray-700">
                <Link href={`/blog/${slug}`}>
                  <h3 className="text-xl font-bold mb-2">{title}</h3>
                  <div className="text-gray-500 text-sm mb-2">{date}</div>
                  {description && <p className="text-gray-700 dark:text-gray-300">{description}</p>}
                </Link>
              </li>
            ))}
          </ul>
        ) : (
          <p className="text-gray-500">No posts found.</p>
        )}
        
        <div className="mt-8 text-center">
          <Link href="/blog" className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors inline-block">
            View All Posts
          </Link>
        </div>
      </div>
    </div>
  )
}

2-10단계: 콘텐츠 작성

실제 블로그 포스트(content/posts/hello-world.mdx)를 작성하여 테스트합니다.

---
title: '헬로 월드'
date: '2023-10-01'
description: '새로운 블로그에 오신 것을 환영합니다! 풍부한 콘텐츠와 함께하세요.'
tags: ['nextjs', 'mdx', 'math', 'tables']
---

> Next.js 16과 MDX로 만들어진 새로운 블로그에 오신 것을 환영합니다.

## 코드 스니펫

코드 예제입니다:

\`\`\`javascript
console.log('Hello, World!')
\`\`\`

## 수식

KaTeX를 사용하여 수식을 렌더링할 수도 있습니다:

인라인 수식: $E = mc^2$

블록 수식:

$$
L = \frac{1}{2} \rho v^2 S C_L
$$

... (생략)

2-11단계: GitHub CSS 적용

import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import 'katex/dist/katex.min.css'
import 'github-markdown-css/github-markdown-light.css'

const geistSans = Geist({
  variable: "--font-geist-sans",
  subsets: ["latin"],
});

const geistMono = Geist_Mono({
  variable: "--font-geist-mono",
  subsets: ["latin"],
});

export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body
        className={`${geistSans.variable} ${geistMono.variable} antialiased`}
      >
        {children}
      </body>
    </html>
  );
}

2-12단계: Github Actions을 사용한 배포 설정

GitHub Pages에 자동 배포하기 위한 워크플로우를 설정합니다.

  • GitHub 저장소 설정
    • GitHub 저장소 생성 후 코드 푸시
    • Settings > Pages에서 소스 선택 (GitHub Actions)
    • next.js 선택 후 commit 진행

3. 확장 기능

  • 검색 기능
  • 태그 필터링
  • 다크 모드
  • 댓글 시스템 (Utterances 등)
  • RSS 피드
  • 사이트맵 생성

4. 참고 자료