Next.js + MDX로 블로그 만들기 (실전 튜토리얼)
Next.js App Router와 MDX를 활용한 블로그 구축 과정. 프로젝트 셋업부터 배포까지.
블로그를 만들 때 선택지가 많다. WordPress, Ghost, Tistory 같은 플랫폼을 쓸 수도 있고, 직접 만들 수도 있다. 개발자라면 직접 만드는 쪽이 끌리는데, 그중에서도 Next.js + MDX 조합은 꽤 매력적이다.
왜냐하면 MDX 파일 하나가 곧 블로그 포스트가 되니까. 마크다운으로 글을 쓰면서 필요하면 React 컴포넌트를 직접 끼워넣을 수 있다. CMS 없이 파일 시스템만으로 콘텐츠를 관리하고, 빌드 타임에 정적 페이지로 변환되니까 성능도 좋다.
이 글에서는 Next.js App Router 기반으로 MDX 블로그를 처음부터 만드는 과정을 다룬다.
프로젝트 셋업
Next.js 프로젝트부터 만든다.
npx create-next-app@latest my-blog --typescript --tailwind --app --src-dir
cd my-blog
MDX 처리에 필요한 패키지를 설치한다.
npm install gray-matter next-mdx-remote remark-gfm rehype-pretty-code shiki
각 패키지의 역할:
- gray-matter — MDX 파일에서 frontmatter(메타데이터)를 파싱
- next-mdx-remote — MDX를 React 컴포넌트로 변환
- remark-gfm — GitHub Flavored Markdown 지원 (표, 체크리스트 등)
- rehype-pretty-code — 코드 블록 구문 강조
- shiki — rehype-pretty-code의 구문 강조 엔진
콘텐츠 구조
프로젝트 루트에 content/blog 폴더를 만들고, 여기에 MDX 파일을 넣는다.
my-blog/
├── content/
│ └── blog/
│ ├── first-post.mdx
│ └── second-post.mdx
├── src/
│ └── app/
│ ├── blog/
│ │ ├── page.tsx ← 글 목록
│ │ └── [slug]/
│ │ └── page.tsx ← 글 상세
│ └── page.tsx
└── ...
MDX 파일은 이런 형태다.
---
title: "첫 번째 글"
description: "이건 첫 번째 블로그 글이다."
date: "2026-01-01"
tags: ["블로그", "시작"]
---
여기부터 본문이다. 마크다운 문법을 그대로 쓸 수 있다.
## 소제목도 된다
코드 블록도 가능하고:
\`\`\`typescript
const greeting = "안녕하세요";
\`\`\`
**볼드**, *이탤릭*, [링크](https://example.com) 전부 된다.
--- 사이에 있는 부분이 frontmatter다. 글의 메타데이터(제목, 설명, 날짜 등)를 여기에 쓴다.
Frontmatter 파싱
gray-matter로 MDX 파일에서 메타데이터와 본문을 분리한다.
// src/lib/blog.ts
import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';
const BLOG_DIR = path.join(process.cwd(), 'content/blog');
export interface BlogPost {
slug: string;
title: string;
description: string;
date: string;
tags: string[];
content: string;
}
export function getAllPosts(): BlogPost[] {
const files = fs.readdirSync(BLOG_DIR);
const posts = files
.filter((file) => file.endsWith('.mdx'))
.map((file) => {
const slug = file.replace('.mdx', '');
const raw = fs.readFileSync(path.join(BLOG_DIR, file), 'utf-8');
const { data, content } = matter(raw);
return {
slug,
title: data.title,
description: data.description,
date: data.date,
tags: data.tags || [],
content,
};
})
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
return posts;
}
export function getPostBySlug(slug: string): BlogPost | undefined {
const filePath = path.join(BLOG_DIR, `${slug}.mdx`);
if (!fs.existsSync(filePath)) return undefined;
const raw = fs.readFileSync(filePath, 'utf-8');
const { data, content } = matter(raw);
return {
slug,
title: data.title,
description: data.description,
date: data.date,
tags: data.tags || [],
content,
};
}
getAllPosts는 전체 글 목록, getPostBySlug는 특정 글 하나를 가져온다. 날짜 기준 내림차순 정렬을 하니까 최신 글이 먼저 나온다.
글 목록 페이지
// src/app/blog/page.tsx
import Link from 'next/link';
import { getAllPosts } from '@/lib/blog';
export default function BlogListPage() {
const posts = getAllPosts();
return (
<main className="max-w-2xl mx-auto px-4 py-12">
<h1 className="text-3xl font-bold mb-8">블로그</h1>
<ul className="space-y-6">
{posts.map((post) => (
<li key={post.slug}>
<Link href={`/blog/${post.slug}`} className="group block">
<time className="text-sm text-gray-500">{post.date}</time>
<h2 className="text-xl font-semibold group-hover:text-blue-600 transition-colors">
{post.title}
</h2>
<p className="text-gray-600 mt-1">{post.description}</p>
</Link>
</li>
))}
</ul>
</main>
);
}
Server Component라서 "use client" 없이 파일 시스템에 직접 접근한다. 빌드 타임에 정적으로 렌더링되니까 런타임에 파일을 읽는 비용이 없다.
글 상세 페이지
여기가 핵심이다. MDX 문자열을 React 컴포넌트로 변환해야 한다.
// src/app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';
import { MDXRemote } from 'next-mdx-remote/rsc';
import remarkGfm from 'remark-gfm';
import rehypePrettyCode from 'rehype-pretty-code';
import { getAllPosts, getPostBySlug } from '@/lib/blog';
export function generateStaticParams() {
return getAllPosts().map((post) => ({ slug: post.slug }));
}
export default function BlogPostPage({
params,
}: {
params: { slug: string };
}) {
const post = getPostBySlug(params.slug);
if (!post) notFound();
return (
<article className="max-w-2xl mx-auto px-4 py-12">
<header className="mb-8">
<time className="text-sm text-gray-500">{post.date}</time>
<h1 className="text-3xl font-bold mt-2">{post.title}</h1>
<p className="text-gray-600 mt-2">{post.description}</p>
</header>
<div className="prose prose-lg dark:prose-invert">
<MDXRemote
source={post.content}
options={{
mdxOptions: {
remarkPlugins: [remarkGfm],
rehypePlugins: [
[rehypePrettyCode, { theme: 'github-dark' }],
],
},
}}
/>
</div>
</article>
);
}
next-mdx-remote/rsc는 Server Component에서 MDX를 렌더링한다. generateStaticParams로 모든 slug를 미리 생성하니까 정적 빌드가 된다.
prose 클래스는 Tailwind Typography 플러그인이 제공하는 건데, 마크다운에서 변환된 HTML에 기본 타이포그래피 스타일을 입혀준다. 이거 없으면 h2, p, ul 같은 요소에 스타일이 안 먹어서 밋밋하다.
npm install @tailwindcss/typography
커스텀 MDX 컴포넌트
MDX의 진짜 힘은 React 컴포넌트를 끼워넣을 수 있다는 것이다. 마크다운 기본 요소를 커스텀 컴포넌트로 대체하거나, 완전히 새로운 컴포넌트를 추가할 수 있다.
// src/components/mdx/Callout.tsx
interface CalloutProps {
type?: 'info' | 'warning' | 'error';
children: React.ReactNode;
}
export function Callout({ type = 'info', children }: CalloutProps) {
const styles = {
info: 'bg-blue-50 border-blue-500 dark:bg-blue-950',
warning: 'bg-yellow-50 border-yellow-500 dark:bg-yellow-950',
error: 'bg-red-50 border-red-500 dark:bg-red-950',
};
return (
<div className={`border-l-4 p-4 my-4 rounded-r ${styles[type]}`}>
{children}
</div>
);
}
이걸 MDX에서 쓰려면 MDXRemote의 components prop에 넘겨준다.
<MDXRemote
source={post.content}
components={{ Callout }}
options={{ /* ... */ }}
/>
그러면 MDX 파일에서 이렇게 쓸 수 있다:
<Callout type="warning">
이건 경고 메시지다. 주의해야 할 내용을 여기에 쓴다.
</Callout>
SEO 설정
블로그라면 SEO를 빼놓을 수 없다.
메타데이터
Next.js App Router에서는 generateMetadata 함수로 페이지별 메타데이터를 동적으로 생성한다.
// src/app/blog/[slug]/page.tsx에 추가
export function generateMetadata({ params }: { params: { slug: string } }) {
const post = getPostBySlug(params.slug);
if (!post) return {};
return {
title: post.title,
description: post.description,
openGraph: {
title: post.title,
description: post.description,
type: 'article',
publishedTime: post.date,
},
};
}
사이트맵
app/sitemap.ts 파일을 만들면 Next.js가 자동으로 /sitemap.xml을 생성한다.
// src/app/sitemap.ts
import { getAllPosts } from '@/lib/blog';
export default function sitemap() {
const posts = getAllPosts().map((post) => ({
url: `https://yourdomain.com/blog/${post.slug}`,
lastModified: new Date(post.date),
}));
return [
{ url: 'https://yourdomain.com', lastModified: new Date() },
{ url: 'https://yourdomain.com/blog', lastModified: new Date() },
...posts,
];
}
RSS 피드
RSS는 아직도 쓰는 사람이 있다. app/feed.xml/route.ts에 Route Handler로 만들면 된다. rss 패키지를 쓰면 간단하다.
Tailwind로 스타일링
@tailwindcss/typography 플러그인의 prose 클래스가 기본 스타일을 잡아주지만, 커스텀이 필요하면 CSS로 덮어쓴다.
/* 코드 블록 스타일 커스텀 */
.prose pre {
@apply rounded-lg border border-gray-200 dark:border-gray-700;
}
.prose code {
@apply text-sm;
}
/* 인라인 코드 */
.prose :not(pre) > code {
@apply bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded text-sm;
}
다크 모드는 dark:prose-invert로 기본 대응이 되고, 세부 조정이 필요하면 dark: 프리픽스로 개별 스타일을 건다.
Vercel 배포
Next.js 프로젝트는 Vercel에 배포하는 게 가장 간편하다.
# Vercel CLI 설치
npm i -g vercel
# 배포
vercel
GitHub 저장소를 Vercel에 연결해두면 push할 때마다 자동 배포된다. PR을 올리면 프리뷰 URL도 자동으로 생성되니까 변경사항을 배포 전에 확인할 수 있다.
빌드 타임에 MDX 파일을 읽어서 정적 페이지로 만드니까, 글이 몇 백 개가 되어도 서버 부담 없이 빠르게 서빙된다.
확장 아이디어
기본 구조가 잡히면 이런 것들을 추가할 수 있다.
- 카테고리/태그 필터링. frontmatter의 tags 필드를 파싱해서 태그별 목록 페이지를 만든다.
- 검색. Fuse.js 같은 클라이언트 사이드 퍼지 검색 라이브러리를 붙이면 별도 서버 없이 검색이 가능하다.
- 목차(TOC). MDX의 heading을 파싱해서 사이드바 목차를 자동 생성한다.
- 읽기 시간 추정. 한국어 기준 분당 500자 정도로 계산하면 된다.
- OG 이미지 자동 생성.
@vercel/og로 글 제목 기반의 OG 이미지를 동적으로 만들 수 있다.
MDX 블로그의 장점은 구조가 단순하다는 것이다. 데이터베이스도 없고, CMS도 없다. 파일 하나가 글 하나. git으로 버전 관리가 되고, 에디터에서 마크다운으로 글을 쓰면 그게 곧 배포된다. 개발자에게는 이게 가장 자연스러운 글쓰기 워크플로우가 아닐까.