Next.js와 MDX로 블로그 제작하기
- Tags
- MDX·Next.js·React
- Published
프론트엔드 개발자로서 개인 사이트를 갖고 싶다는 열망이 항상 있었습니다. 하지만 가뜩이나 프로젝트에 치여 시간이 부족한데, 블로그를 운영하기 위한 CMS를 직접 제작하는 것은 결코 쉽지 않은 결정입니다. 그러던 와중 Next.js의 Static Site Generation과 MDX를 활용한 접근법을 알게 되었고, 마침 프로젝트 공백기를 맞아 본격적으로 개인 포트폴리오 사이트 겸 블로그를 개발하게 되었습니다.
이 포스트에서는 제 블로그의 핵심 구조, 그중에서도 파일 기반의 포스트/프로젝트 관리 시스템을 중심으로 설명합니다.
기술 스택
MDX
MDX는 마크다운 문서 안에 JSX를 함께 사용할 수 있게 해주는 문법입니다. 일반 마크다운처럼 제목, 문단, 목록, 코드 블록을 작성하면서도, 필요한 부분에는 React 컴포넌트를 직접 삽입할 수 있습니다.
- Next.js
<Image>컴포넌트를 바로 삽입하여 Next.js 이미지 최적화를 바로 사용할 수 있습니다. - JSX 모듈처럼 관리되기 때문에
<embed><iframe><video>등의 태그를 자유롭게 사용할 수 있습니다. - 마크다운이 지원하지 않는 첨부파일 삽입 기능도
<File>컴포넌트를 만들어 처리하면 됩니다. - 프로젝트 소개 페이지에 인터렉티브 데모가 필요하다면 곧바로 React 컴포넌트를 작성하여 삽입하면 됩니다.
Next.js
Next.js는 React SSR 기반의 웹 프레임워크로, 요청 시점에 서버에서 페이지를 렌더링한다는 점에서 React로 작성하는 PHP에 가깝습니다.
- Vercel과 OpenNext를 사용하여
CI/CD 파이프라인을 구성하기가 매우 용이합니다.
main에 커밋하면 즉시 사이트가 배포되도록 할 수 있습니다. - Next.js의 Static Site Generation (SSG)는 빌드 타임에 컨텐츠를 정적 컨텐츠로 변환합니다. Vercel 또는 OpenNext를 사용하면, 해당 컨텐츠를 손쉽게 CDN으로 배포할 수 있습니다. 따라서 접속 속도가 빨라지며, 동적 생성을 위한 컴퓨팅 자원이 필요없습니다.
대부분의 개발자들은 IDE를 사용한 마크다운 문서 작성이 이미 익숙합니다. 위의 특성을 활용하여 레포지터리 내에 문서를 바로 작성하고, 이를 Commit/Push하여 컨텐츠를 즉시 배포할 수 있는 환경을 구성하면 별도의 CMS 소프트웨어 없이 블로그를 운영할 수 있습니다.
MDX 설정
우선 필요한 모듈을 다음과 같이 설치합니다.
pnpm add @next/mdx @mdx-js/loader @mdx-js/react
pnpm add remark-gfm remark-math remark-mdx remark-parse
pnpm add rehype-katex rehype-slug rehype-mdx-import-media rehype-unwrap-images rehype-slug
pnpm add shiki @shikijs/rehype unified unist-util-visit
pnpm add -D @types/mdxremark 및 rehype 플러그인의 경우 본인이 필요한 범위에 한하여 선택하여 사용하셔도 됩니다.
해당 플러그인을 설치한 후, next.config.ts를 다음과 같이 설정합니다.
import type { NextConfig } from 'next';
import createMDX from '@next/mdx';
const config: NextConfig = {
reactCompiler: true,
pageExtensions: ['js', 'jsx', 'md', 'mdx', 'ts', 'tsx'],
...
};
const withMDX = createMDX({
extension: /\.(md|mdx)$/,
options: {
remarkPlugins: ['remark-gfm', 'remark-math'],
rehypePlugins: [
'rehype-katex',
'rehype-slug',
'rehype-unwrap-images',
'rehype-mdx-import-media',
'@insd47/rehype-fence',
],
},
});
export default withMDX(config);- config를
withMDX로 감싸 내보내는 방식입니다. @insd47/rehype-fence는 코드블럭에 제목을 삽입하기 위하여 제가 개인적으로 사용하는@shikijs/rehype의 확장입니다. 사용하지 않으신다면 이를@shikijs/rehype로 바꾸어 주세요.rehype-mdx-import-media는 마크다운 이미지 문법를 사용하여 로컬 이미지를import하기 위한 플러그인입니다. 이를 사용하지 않고<Image>컴포넌트를 바로 사용하셔도 됩니다.rehype-mdx-unwrap-images는 위의 사용에서,<img>태그 대신<figure>를 사용하여 캡션을 표시하기 위해<p>내부의 단일 이미지를 평탄화하는 플러그인입니다. 마찬가지로<Image>컴포넌트를 바로 사용하셔도 됩니다.
또한 src 디렉터리 루트에 mdx-components.tsx가 필요합니다.
import type { MDXComponents } from 'mdx/types';
import Separator from '@/components/separator';
import { Paragraph, Heading2, Heading3, Heading4 } from '@/components/mdx/typography';
import { Blockquote } from '@/components/mdx/blockquote';
import { Pre } from '@/components/mdx/fence';
import { OrderedList, UnorderedList } from '@/components/mdx/list';
import { Anchor, Code } from '@/components/mdx/inline';
import { Image } from '@/components/mdx/image';
export const mdxComponents: MDXComponents = {
p: Paragraph,
h1: () => null,
h2: Heading2,
h3: Heading3,
h4: Heading4,
h5: Paragraph,
H6: Paragraph,
hr: Separator,
pre: Pre,
code: Code,
blockquote: Blockquote,
ul: UnorderedList,
ol: OrderedList,
a: Anchor,
img: Image,
};
export function useMDXComponents(): MDXComponents {
return mdxComponents;
}- 위 2개의 모듈이 반드시 정의되어 있어야 합니다.
mdxComponents는 마크다운 문법에 대한 React 컴포넌트의 매핑입니다. 반드시 해당하는 element가 반환되어야 하는 것은 아니므로, 자유롭게 컴포넌트를 작성하시면 됩니다.
저는 개인적으로, 다음과 같이 Tailwind를 사용하여 컴포넌트를 확장하는 패턴을 즐겨 사용합니다.
import type { ComponentProps } from 'react';
import { cn } from '@/lib/utils/cn';
export function Paragraph({ className, ...props }: ComponentProps<'p'>) {
return <p {...props} className={cn('font-sans first:mt-8 last:mb-8', className)} />;
}
export function Heading2({ className, ...props }: ComponentProps<'h2'>) {
return (
<h2
{...props}
className={cn(
'mt-9 text-foreground font-bold scroll-m-20 pb-3 border-b text-xl leading-tight',
className,
)}
/>
);
}
export function Heading3({ className, ...props }: ComponentProps<'h3'>) {
return (
<h3
{...props}
className={cn('mt-6 text-foreground font-bold scroll-m-20 text-base', className)}
/>
);
}
...각 컴포넌트의 상세한 구현이 궁금하다면 소스를 참고해주세요.
마크다운 문서 작성
현재 보고 계시는 게시물의 소스 코드는 다음과 같습니다.
import thumbnail from './thumbnail.png';
# Next.js와 MDX로 블로그 제작하기
export const metadata = {
tags: ['MDX', 'Next.js', 'React'],
published: '2026-06-27',
thumbnail,
};

프론트엔드 개발자로서 개인 사이트를 갖고 싶다는 열망이 항상 있었습니다.
...- 마크다운의 형식을 존중하여, 제목은
#를 사용하여 작성합니다. - 포스트 메타데이터는
metadata모듈로 export합니다. 태그, 게시일, 썸네일 등의 정보를 포함합니다. - 아래부터는 본격적으로 컨텐츠를 작성하며, 최상단에는 명시적으로 썸네일을 삽입했습니다.
디렉터리 구조
컨텐츠는 src 아래의 독립적인 디렉터리에 저장합니다.
src
├── app
│ ├── posts
│ │ └── [slug]
│ └── posts
│ └── [slug]
├── components
├── content
│ ├── posts
│ │ └── next-mdx-blog
│ │ ├── thumbnail.png
│ │ └── post.mdx
│ └── projects
│ ├── kc
│ │ ├── banner.png
│ │ └── project.mdx
│ └── rv
│ ├── banner.png
│ └── project.mdx
└── mdx-components.tsxposts는 게시물,projects는 프로젝트 문서를 저장합니다.- 게시물에 사용되는 이미지, 파일 등을 해당 게시물 문서와 함께 관리하기 위해, 각 게시물이 독립적인 폴더를 가지도록 구성하였습니다.
작성한 컨텐츠 사용하기
이 블로그에는 2가지의 게시물 타입이 존재합니다:
- Post: 블로그 게시물. 일반적으로 목차가 존재하는 줄글의 형태이며 태그를 통한 분류가 필요합니다.
- Project: 프로젝트 문서. 랜딩 페이지의 성격이 강하며, 목차가 필요없고 페이지를 넓게 사용합니다.
두 게시물 타입을 모두 MDX 형태로 관리하기 위하여, 아래의 공통 importer를 만들었습니다.
import { readFile, readdir } from 'node:fs/promises';
import { z } from 'zod';
import type { ComponentType } from 'react';
import { unified } from 'unified';
import { visit } from 'unist-util-visit';
import { toString } from 'mdast-util-to-string';
import type { Heading, Root } from 'mdast';
import nodePath from 'node:path';
import remarkParse from 'remark-parse';
import remarkMdx from 'remark-mdx';
import { truncate } from '@/lib/utils/text';
import { execFile as execFileRaw } from 'node:child_process';
import { promisify } from 'node:util';
/**
* 특정 경로의 모든 파일명을 반환합니다.
* @param dir 경로. `src` 기준입니다.
*/
export async function importList(dir: string) {
const directory = nodePath.join(process.cwd(), 'src', dir);
const entries = await readdir(directory, { withFileTypes: true });
return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
}
/**
* Markdown 문서의 요약 데이터를 불러옵니다.
* @param path 마크다운 문서의 경로. `src` 기준입니다.
*/
export async function importSummary(path: string) {
const target = nodePath.join(process.cwd(), 'src', path);
const source = await readFile(target, 'utf8');
const tree = unified().use(remarkParse).use(remarkMdx).parse(source) as Root;
let title: string | undefined;
const sections: string[] = [];
visit(tree, 'heading', (node: Heading) => {
const text = toString(node).trim();
if (!text) return;
if (node.depth === 1 && !title) title = text;
else if (node.depth === 2) sections.push(text);
});
if (!title) {
throw new Error(`Expected first markdown heading to be a level 1 title: ${path}`);
}
const description = tree.children
.filter((node) => node.type === 'paragraph')
.map((node) => toString(node))
.map((text) => text.replace(/\s+/g, ' ').trim())
.filter((text) => text.length > 0)
.map((text) => truncate(text, 100))
.at(0);
return { title, sections, description };
}
/**
* Markdown 컨텐츠를 불러오고 지정한 스키마로 메타데이터를 파싱합니다.
* @param module 마크다운 모듈. mdx 파일에 대한 동적 import를 전달해야 합니다.
* @param scheme `metadata`에 대한 스키마
*/
export async function importDocument<T extends z.ZodType>(
module: Promise<unknown>,
scheme: T,
): Promise<{ Content: ComponentType; metadata: z.output<T> }> {
const { default: Content, metadata } = (await module) as {
default: ComponentType;
metadata: unknown;
};
return { Content, metadata: scheme.parse(metadata) };
}
/**
* Git을 사용하여 문서의 마지막 수정일을 가져옵니다.
* @param path 문서의 경로. `src` 기준입니다.
*/
export async function importDate(path: string) {
const params = ['log', '-1', '--format=%cI', '--', `src/${path}`];
const options = { cwd: process.cwd() };
const { stdout } = await execFile('git', params, options);
const value = stdout.trim();
if (!value) {
throw new Error(`Failed to get date from git log: ${path}`);
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
throw new Error(`Invalid date format: ${value}`);
}
return date;
}
const execFile = promisify(execFileRaw);- 각 importer를 단일 비동기 작업 단위로 잘게 나누었습니다. 이렇게 나누면 여러 작업을
Promise.all()로 묶어 병렬로 처리할 수 있습니다. - 마크다운 본연의 형식을 존중하기 위하여, 메타데이터가 아닌
#을 사용하여 제목을 처리하도록 구성하였습니다. - 문서를 모듈로써 불러와야 얻을 수 있는 데이터와, 문서를 텍스트 데이터로써 불러와야 얻을 수 있는 데이터를 분리하여 처리합니다.
sitemap.xml,rss.xml등에 '최종 수정일'을 간편하게 보고하기 위하여, Git을 사용한 수정일 관리 로직을 사용합니다.
import { importDocument, importSummary, importList, importDate } from '@/lib/utils/content';
import { z } from 'zod';
import { StaticImageData } from 'next/image';
import { ComponentType } from 'react';
export async function getPostList() {
const slugs = await importList('content/posts');
const items = await Promise.all(slugs.map(getPost));
return items.sort((a, b) => {
const x = a.published;
const y = b.published;
return y.getTime() - x.getTime();
});
}
export async function getPost(slug: string): Promise<Post> {
const [{ Content, metadata }, summary, date] = await Promise.all([
importDocument(import(`@/content/posts/${slug}/post.mdx`), scheme),
importSummary(`content/posts/${slug}/post.mdx`),
importDate(`content/posts/${slug}/post.mdx`),
]);
return { slug, ...summary, ...metadata, date, Content };
}
const scheme = z.object({
tags: z.array(z.string()),
published: z.string().pipe(z.coerce.date()),
thumbnail: z.custom<StaticImageData>(),
});
export interface Post extends z.infer<typeof scheme> {
slug: string;
title: string;
sections: string[];
description?: string;
date: Date;
Content: ComponentType;
}- importer 모듈, 컨텐츠 경로와 스키마를 사용하여 페이지 및 컴포넌트에서 사용할 데이터를 조립합니다.
- 날짜를 편하게 작성하기 위하여 string으로 정의하고
z.pipe()를 사용하여Date로 변환합니다. - 동적
import를 사용할 때, 번들러 동작 상 확장자를 포함한 경로 표현식이 필요하기 때문에import문 자체를 인자로 넘겨주도록 작성하였습니다.
정적 사이트 생성
Next.js에서 Dynamic Route를 사용하는 페이지는 기본적으로 요청 시점에 렌더링되는 동적 페이지로 처리됩니다. 따라서 Static Site Generation을 적용하기 위해서는 다음과 같이 작성해야 합니다.
export async function generateStaticParams() {
return getPostList().then((posts) => posts.map(({ slug }) => ({ slug })));
}
export const dynamicParams = false;generateStaticParams()를 사용하여 정적 페이지를 생성할 파라메터 목록을 넘겨줍니다.dynamicParams를false로 지정하여, 빌드 시 생성되지 않은 경로를 런타임에 동적으로 만들지 못하도록 막습니다.
마치며
이번 블로그 프로젝트의 구조는 굉장히 단순합니다.
- 레포지터리 내에 MDX 게시물을 작성합니다.
- 빌드 시 해당 게시물을 파싱하여 HTML 정적 컨텐츠로 번들링합니다. 이 때
node:path와 동적import를 활용합니다. - Vercel 또는 OpenNext를 사용하여 CDN에 배포합니다.
마크다운 문법과 Git에 이미 익숙하다면, 이처럼 간단한 설정만으로 별도의 CMS 없이 블로그를 운영할 수 있습니다. 물론 모든 컨텐츠를 빌드 타임에 정적으로 생성하는 만큼, 게시물이 늘어날수록 빌드 타임이 길어지고 Asset이 레포지터리에 함께 쌓여 용량이 증가한다는 한계도 존재합니다. 그럼에도 복잡한 운영 부담 없이 블로그를 가볍게 유지하고 싶다면, 이 방식은 더할 나위 없이 좋은 선택입니다.