How to use markdown and MDX in Next.js

김동현·2026년 3월 4일

next.js 공식문서 번역

목록 보기
28/79

마크다운(Markdown)은 텍스트의 서식을 지정할 때 사용하는 가벼운 마크업 언어예요. 일반 텍스트 문법을 사용해서 글을 작성하면, 구조적으로 유효한 HTML로 변환할 수 있게 해주죠. 웹사이트나 블로그에서 콘텐츠를 작성할 때 아주 흔하게 사용된답니다.

예를 들어 이렇게 작성하면...

I **love** using [Next.js](https://nextjs.org/)

결과물은 이렇게 나옵니다:

<p>I <strong>love</strong> using <a href="https://nextjs.org/">Next.js</a></p>

MDX는 마크다운의 상위 집합(Superset)으로, 마크다운 파일 안에서 직접 JSX를 작성할 수 있게 해주는 아주 멋진 녀석이에요. 여러분의 콘텐츠 안에 동적인 상호작용을 추가하거나 React 컴포넌트를 그대로 임베드할 수 있는 강력한 방법이죠.

💡 강사의 덧붙임: > 예전에는 마크다운으로 쓴 글 중간에 버튼이나 차트 같은 React 컴포넌트를 넣는 게 정말 까다로웠어요. 하지만 MDX를 사용하면 <MyCustomChart /> 처럼 우리가 만든 리액트 컴포넌트를 마크다운 파일에 바로 넣을 수 있습니다. 요즘 개발자 블로그나 디자인 시스템 문서(예: Storybook)를 보면 대부분 이 MDX를 활용하고 있답니다!

Next.js는 애플리케이션 내부에 있는 로컬 MDX 콘텐츠뿐만 아니라, 서버에서 동적으로 가져오는 원격(remote) MDX 파일도 모두 지원해요. Next.js 플러그인이 마크다운과 React 컴포넌트를 HTML로 변환하는 작업을 알아서 처리해주며, App Router의 기본값인 서버 컴포넌트(Server Components)에서의 사용도 완벽하게 지원합니다.

알아두면 좋은 점: 완전하게 작동하는 예제를 보고 싶다면 포트폴리오 스타터 키트(Portfolio Starter Kit) 템플릿을 확인해 보세요.

의존성 패키지 설치하기 (Install dependencies)

Next.js가 마크다운과 MDX를 처리할 수 있도록 설정하려면 @next/mdx 패키지와 관련 패키지들이 필요해요. 이 패키지는 로컬 파일에서 데이터를 가져오며, /pages 또는 /app 디렉토리 안에 직접 .md.mdx 확장자를 가진 페이지를 만들 수 있게 해줍니다.

Next.js에서 MDX를 렌더링하려면 다음 패키지들을 설치해 주세요:

pnpm add @next/mdx @mdx-js/loader @mdx-js/react @types/mdx
npm install @next/mdx @mdx-js/loader @mdx-js/react @types/mdx
yarn add @next/mdx @mdx-js/loader @mdx-js/react @types/mdx
bun add @next/mdx @mdx-js/loader @mdx-js/react @types/mdx

💡 강사의 실무 팁:
여기서 @types/mdx를 꼭 설치하셔야 합니다! TypeScript 환경에서 개발하실 때 이 타입 정의 파일이 없으면, .mdx 파일을 임포트할 때마다 빨간 줄(타입 에러)을 마주하게 될 거예요. 미리 챙겨두는 센스!

next.config.mjs 설정하기

프로젝트 루트에 있는 next.config.mjs 파일을 수정해서 MDX를 사용하도록 설정해 볼게요:

//filename="next.config.mjs"
import createMDX from '@next/mdx'

/** @type {import('next').NextConfig} */
const nextConfig = {
  // Configure `pageExtensions` to include markdown and MDX files
  pageExtensions: ['js', 'jsx', 'md', 'mdx', 'ts', 'tsx'],
  // Optionally, add any other Next.js config below
}

const withMDX = createMDX({
  // Add markdown plugins here, as desired
})

// Merge MDX config with Next.js config
export default withMDX(nextConfig)

이렇게 설정하면 우리 애플리케이션에서 .mdx 파일이 페이지로, 라우트로, 또는 임포트할 수 있는 모듈로 동작할 수 있게 됩니다.

💡 강사의 덧붙임:
파일 이름이 next.config.js가 아니라 .mjs인 이유는 최신 Node.js 환경에서 ECMAScript Modules(ESM)인 import / export 문법을 기본적으로 원활하게 사용하기 위함이에요.

.md 파일 처리하기

기본적으로 next/mdx.mdx 확장자를 가진 파일만 컴파일합니다. webpack에서 일반 .md 파일도 함께 처리하게 하려면 extension 옵션을 업데이트해 주셔야 해요:

//filename="next.config.mjs"
const withMDX = createMDX({
  extension: /\.(md|mdx)$/,
})

mdx-components.tsx 파일 추가하기

전역 MDX 컴포넌트를 정의하기 위해 프로젝트 루트에 mdx-components.tsx (또는 .js) 파일을 만들어 주세요. 예를 들어, pagesapp 디렉토리와 같은 레벨에 두거나, src 디렉토리를 사용 중이라면 그 안에 만드시면 됩니다.

//filename="mdx-components.tsx" switcher
import type { MDXComponents } from 'mdx/types'

const components: MDXComponents = {}

export function useMDXComponents(): MDXComponents {
  return components
}
//filename="mdx-components.js" switcher
const components = {}

export function useMDXComponents() {
  return components
}

알아두면 좋은 점:

💡 강사의 주의사항:
"설정 다 했는데 왜 에러가 나지?" 하면 십중팔구 mdx-components.tsx 파일을 루트 경로에 안 만들었기 때문이에요. 빈 껍데기 함수라도 좋으니 반드시 생성해 두셔야 합니다!

MDX 렌더링하기 (Rendering MDX)

Next.js의 파일 기반 라우팅(file based routing)을 사용하거나 다른 페이지로 MDX 파일을 임포트해서 MDX를 렌더링할 수 있습니다.

파일 기반 라우팅 사용하기

파일 기반 라우팅을 사용할 때는 MDX 페이지를 다른 일반 페이지와 똑같이 사용할 수 있어요.

App Router 앱에서는 메타데이터(metadata)도 사용할 수 있다는 뜻이죠.

/app 디렉토리 안에 새로운 MDX 페이지를 만들어 볼까요?

  my-project
  ├── app
  │   └── mdx-page
  │       └── page.(mdx/md)
  |── mdx-components.(tsx/js)
  └── package.json

이러한 파일들 안에서 MDX를 사용할 수 있고, 심지어 React 컴포넌트를 MDX 페이지 안에 직접 임포트할 수도 있습니다:

import { MyComponent } from 'my-component'

# Welcome to my MDX page!

This is some **bold** and _italics_ text.

This is a list in markdown:

- One
- Two
- Three

Checkout my React component:

<MyComponent />

브라우저에서 /mdx-page 라우트로 이동해 보면 렌더링된 MDX 페이지가 나타날 거예요.

임포트해서 사용하기 (Using imports)

/app 디렉토리 안에 새로운 페이지를 만들고, MDX 파일은 여러분이 원하는 곳 아무데나 만들어 보세요:

  .
  ├── app/
  │   └── mdx-page/
  │       └── page.(tsx/js)
  ├── markdown/
  │   └── welcome.(mdx/md)
  ├── mdx-components.(tsx/js)
  └── package.json

이 파일들 안에서도 MDX를 사용할 수 있고, React 컴포넌트도 똑같이 임포트할 수 있습니다:

//filename="markdown/welcome.mdx" switcher
import { MyComponent } from 'my-component'

# Welcome to my MDX page!

This is some **bold** and _italics_ text.

This is a list in markdown:

- One
- Two
- Three

Checkout my React component:

<MyComponent />

이제 이 콘텐츠를 보여주기 위해 페이지 안에서 해당 MDX 파일을 임포트해 줍니다:

//filename="app/mdx-page/page.tsx" switcher
import Welcome from '@/markdown/welcome.mdx'

export default function Page() {
  return <Welcome />
}
//filename="app/mdx-page/page.js" switcher
import Welcome from '@/markdown/welcome.mdx'

export default function Page() {
  return <Welcome />
}

마찬가지로 /mdx-page 경로로 이동하면 렌더링된 MDX를 볼 수 있습니다.

💡 강사의 덧붙임:
파일 기반 라우팅은 .mdx 파일 자체가 하나의 URL 페이지가 되는 구조고, 임포트 방식은 기존 .tsx 파일 안의 일부분으로서 .mdx 콘텐츠를 끼워 넣는 방식이에요. 재사용이 필요한 콘텐츠라면 임포트 방식을 더 선호하게 되실 겁니다.

동적 임포트 사용하기 (Using dynamic imports)

MDX 파일에 대해 파일 시스템 라우팅을 사용하는 대신, 동적 MDX 컴포넌트를 임포트할 수도 있어요.

예를 들어, 별도의 디렉토리에서 MDX 컴포넌트들을 불러오는 동적 라우트 세그먼트(dynamic route segment)를 만들 수 있죠:

Route segments for dynamic MDX components

generateStaticParams 함수를 사용하면 주어진 라우트들을 미리 사전 렌더링(prerender)할 수 있어요. dynamicParamsfalse로 설정해 두면, generateStaticParams에 정의되지 않은 라우트에 접근했을 때 404 에러가 반환됩니다.

//filename="app/blog/[slug]/page.tsx" switcher
export default async function Page({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  const { default: Post } = await import(`@/content/${slug}.mdx`)

  return <Post />
}

export function generateStaticParams() {
  return [{ slug: 'welcome' }, { slug: 'about' }]
}

export const dynamicParams = false
//filename="app/blog/[slug]/page.js" switcher
export default async function Page({ params }) {
  const { slug } = await params
  const { default: Post } = await import(`@/content/${slug}.mdx`)

  return <Post />
}

export function generateStaticParams() {
  return [{ slug: 'welcome' }, { slug: 'about' }]
}

export const dynamicParams = false

알아두면 좋은 점: 임포트할 때 .mdx 파일 확장자를 꼭 명시해 주세요. 모듈 경로 별칭(module path aliases) (예: @/content)을 사용하는 게 필수는 아니지만, 임포트 경로를 훨씬 깔끔하게 만들어 준답니다.

💡 강사의 실무 팁:
이 패턴은 블로그 시스템을 직접 구축할 때 핵심적인 역할을 합니다. content 폴더 안에 여러 마크다운 글을 넣어두고, 브라우저 주소에 따라 알아서 해당하는 마크다운을 렌더링하게 만드는 거죠. 완전 나만의 블로그 플랫폼이 되는 겁니다!

커스텀 스타일 및 컴포넌트 사용하기

마크다운은 렌더링될 때 네이티브 HTML 요소들로 매핑됩니다. 예를 들어, 아래와 같이 마크다운을 작성하면:

## This is a heading

This is a list in markdown:

- One
- Two
- Three

이런 HTML이 생성되죠:

<h2>This is a heading</h2>

<p>This is a list in markdown:</p>

<ul>
  <li>One</li>
  <li>Two</li>
  <li>Three</li>
</ul>

마크다운에 스타일을 입히기 위해, 생성되는 HTML 요소들에 매핑되는 커스텀 컴포넌트를 제공할 수 있습니다. 스타일과 컴포넌트는 전역으로, 지역으로, 또는 공유 레이아웃을 통해 구현할 수 있어요.

전역 스타일 및 컴포넌트 (Global styles and components)

mdx-components.tsx에 스타일과 컴포넌트를 추가하면 애플리케이션에 있는 모든 MDX 파일에 영향을 줍니다.

//filename="mdx-components.tsx" switcher
import type { MDXComponents } from 'mdx/types'
import Image, { ImageProps } from 'next/image'

// This file allows you to provide custom React components
// to be used in MDX files. You can import and use any
// React component you want, including inline styles,
// components from other libraries, and more.

const components = {
  // Allows customizing built-in components, e.g. to add styling.
  h1: ({ children }) => (
    <h1 style={{ color: 'red', fontSize: '48px' }}>{children}</h1>
  ),
  img: (props) => (
    <Image
      sizes="100vw"
      style={{ width: '100%', height: 'auto' }}
      {...(props as ImageProps)}
    />
  ),
} satisfies MDXComponents

export function useMDXComponents(): MDXComponents {
  return components
}
//filename="mdx-components.js" switcher
import Image from 'next/image'

// This file allows you to provide custom React components
// to be used in MDX files. You can import and use any
// React component you want, including inline styles,
// components from other libraries, and more.

const components = {
  // Allows customizing built-in components, e.g. to add styling.
  h1: ({ children }) => (
    <h1 style={{ color: 'red', fontSize: '48px' }}>{children}</h1>
  ),
  img: (props) => (
    <Image sizes="100vw" style={{ width: '100%', height: 'auto' }} {...props} />
  ),
}

export function useMDXComponents() {
  return components
}

💡 강사의 덧붙임:
일반 마크다운 이미지 태그인 ![alt](url)를 작성해도, 방금 위에서 설정한 것처럼 next/image 컴포넌트로 자동 변환되게 만들 수 있어요! 덕분에 마크다운으로 글을 써도 Next.js의 강력한 이미지 최적화 혜택을 그대로 받을 수 있습니다.

지역 스타일 및 컴포넌트 (Local styles and components)

특정 페이지에만 지역 스타일이나 컴포넌트를 적용하고 싶다면, 임포트한 MDX 컴포넌트의 props로 전달해주면 됩니다. 이 속성들은 전역 스타일 및 컴포넌트와 병합(merge)되며 필요할 경우 덮어쓰게 됩니다.

//filename="app/mdx-page/page.tsx" switcher
import Welcome from '@/markdown/welcome.mdx'

function CustomH1({ children }) {
  return <h1 style={{ color: 'blue', fontSize: '100px' }}>{children}</h1>
}

const overrideComponents = {
  h1: CustomH1,
}

export default function Page() {
  return <Welcome components={overrideComponents} />
}
//filename="app/mdx-page/page.js" switcher
import Welcome from '@/markdown/welcome.mdx'

function CustomH1({ children }) {
  return <h1 style={{ color: 'blue', fontSize: '100px' }}>{children}</h1>
}

const overrideComponents = {
  h1: CustomH1,
}

export default function Page() {
  return <Welcome components={overrideComponents} />
}

공유 레이아웃 (Shared layouts)

MDX 페이지들 사이에서 공통 레이아웃을 사용하려면, App Router의 내장 레이아웃 지원 기능(built-in layouts support)을 활용할 수 있습니다.

//filename="app/mdx-page/layout.tsx" switcher
export default function MdxLayout({ children }: { children: React.ReactNode }) {
  // Create any shared layout or styles here
  return <div style={{ color: 'blue' }}>{children}</div>
}
//filename="app/mdx-page/layout.js" switcher
export default function MdxLayout({ children }) {
  // Create any shared layout or styles here
  return <div style={{ color: 'blue' }}>{children}</div>
}

Tailwind typography 플러그인 사용하기

앱의 스타일링에 Tailwind를 사용 중이시라면, @tailwindcss/typography 플러그인을 사용해 보세요. 마크다운 파일에서도 Tailwind의 설정과 스타일을 그대로 재사용할 수 있게 해줍니다.

이 플러그인은 마크다운처럼 외부 출처에서 온 콘텐츠 블록에 타이포그래피 스타일을 추가할 수 있는 prose 클래스 세트를 제공해요.

Tailwind typography를 설치하시고, 공유 레이아웃과 함께 사용해서 여러분이 원하는 prose 스타일을 추가해 보세요.

//filename="app/mdx-page/layout.tsx" switcher
export default function MdxLayout({ children }: { children: React.ReactNode }) {
  // Create any shared layout or styles here
  return (
    <div className="prose prose-headings:mt-8 prose-headings:font-semibold prose-headings:text-black prose-h1:text-5xl prose-h2:text-4xl prose-h3:text-3xl prose-h4:text-2xl prose-h5:text-xl prose-h6:text-lg dark:prose-headings:text-white">
      {children}
    </div>
  )
}
//filename="app/mdx-page/layout.js" switcher
export default function MdxLayout({ children }) {
  // Create any shared layout or styles here
  return (
    <div className="prose prose-headings:mt-8 prose-headings:font-semibold prose-headings:text-black prose-h1:text-5xl prose-h2:text-4xl prose-h3:text-3xl prose-h4:text-2xl prose-h5:text-xl prose-h6:text-lg dark:prose-headings:text-white">
      {children}
    </div>
  )
}

💡 강사의 실무 팁:
사실 마크다운 콘텐츠마다 일일이 CSS를 작성하는 건 고역입니다. Tailwind 사용 시 이 prose 클래스 하나면 H1~H6, 인용구(blockquote), 리스트 등 마크다운의 모든 요소가 예쁜 기본 디자인으로 싹 바뀝니다. Tailwind 사용자라면 선택이 아닌 필수 플러그인이라고 생각하세요!

프론트매터 (Frontmatter)

프론트매터(Frontmatter)는 페이지에 대한 데이터를 저장하는 데 사용할 수 있는 YAML 형태의 키/값 쌍입니다. @next/mdx는 기본적으로 프론트매터를 지원하지 않지만, MDX 콘텐츠에 프론트매터를 추가할 수 있는 다양한 해결책들이 있어요:

대신 @next/mdx는 다른 자바스크립트 컴포넌트처럼 export를 사용하는 것을 허용합니다:

//filename="content/blog-post.mdx" switcher
export const metadata = {
  author: 'John Doe',
}

# Blog post

이렇게 하면 MDX 파일 외부에서 이 메타데이터를 참조할 수 있어요:

//filename="app/blog/page.tsx" switcher
import BlogPost, { metadata } from '@/content/blog-post.mdx'

export default function Page() {
  console.log('metadata: ', metadata)
  //=> { author: 'John Doe' }
  return <BlogPost />
}
//filename="app/blog/page.js" switcher
import BlogPost, { metadata } from '@/content/blog-post.mdx'

export default function Page() {
  console.log('metadata: ', metadata)
  //=> { author: 'John Doe' }
  return <BlogPost />
}

이 방식이 가장 흔하게 쓰이는 사례는 MDX 컬렉션을 순회하면서 데이터를 추출하고 싶을 때예요. 예를 들어, 모든 블로그 포스트를 모아 블로그 목록 페이지(index page)를 만드는 경우죠. Node의 fs 모듈이나 globby 같은 패키지를 사용하면, 포스트가 모여있는 디렉토리를 읽고 메타데이터를 추출해 낼 수 있답니다.

알아두면 좋은 점:

remark 및 rehype 플러그인

필요하다면 MDX 콘텐츠를 변환하기 위해 remark와 rehype 플러그인을 제공할 수 있습니다.

예를 들어, GitHub Flavored Markdown(GFM, 깃허브 스타일 마크다운)을 지원하기 위해 remark-gfm을 사용할 수 있죠.

remark와 rehype 생태계는 오직 ESM(ECMAScript Modules)으로만 되어 있기 때문에, 설정 파일로 next.config.mjs 또는 next.config.ts를 사용해야 합니다.

//filename="next.config.mjs"
import remarkGfm from 'remark-gfm'
import createMDX from '@next/mdx'

/** @type {import('next').NextConfig} */
const nextConfig = {
  // Allow .mdx extensions for files
  pageExtensions: ['js', 'jsx', 'md', 'mdx', 'ts', 'tsx'],
  // Optionally, add any other Next.js config below
}

const withMDX = createMDX({
  // Add markdown plugins here, as desired
  options: {
    remarkPlugins: [remarkGfm],
    rehypePlugins: [],
  },
})

// Combine MDX and Next.js config
export default withMDX(nextConfig)

Turbopack과 함께 플러그인 사용하기

Turbopack(터보팩)과 함께 플러그인을 사용하려면, 최신 버전의 @next/mdx로 업그레이드한 다음 문자열(string)을 사용해 플러그인 이름을 명시해 주셔야 합니다:

//filename="next.config.mjs"
import createMDX from '@next/mdx'

/** @type {import('next').NextConfig} */
const nextConfig = {
  pageExtensions: ['js', 'jsx', 'md', 'mdx', 'ts', 'tsx'],
}

const withMDX = createMDX({
  options: {
    remarkPlugins: [
      // Without options
      'remark-gfm',
      // With options
      ['remark-toc', { heading: 'The Table' }],
    ],
    rehypePlugins: [
      // Without options
      'rehype-slug',
      // With options
      ['rehype-katex', { strict: true, throwOnError: true }],
    ],
  },
})

export default withMDX(nextConfig)

알아두면 좋은 점:

직렬화(serializable)가 불가능한 옵션을 가진 remark 및 rehype 플러그인은 아직 Turbopack과 함께 사용할 수 없습니다. 자바스크립트 함수를 Rust로 전달할 수 없기 때문이에요.

딥 다이브 (Deep Dive): 마크다운은 어떻게 HTML로 변환될까요?

React는 태생적으로 마크다운을 이해하지 못합니다. 그래서 마크다운 일반 텍스트는 우선 HTML로 변환되어야 하죠. 이 작업은 remarkrehype를 통해 이뤄집니다.

remark는 마크다운을 다루는 생태계 도구입니다. rehype도 마찬가지인데, HTML을 다룬다는 점이 다르죠. 예를 들어, 아래 코드 스니펫은 마크다운을 HTML로 변환하는 과정을 보여줍니다:

import { unified } from 'unified'
import remarkParse from 'remark-parse'
import remarkRehype from 'remark-rehype'
import rehypeSanitize from 'rehype-sanitize'
import rehypeStringify from 'rehype-stringify'

main()

async function main() {
  const file = await unified()
    .use(remarkParse) // Convert into markdown AST
    .use(remarkRehype) // Transform to HTML AST
    .use(rehypeSanitize) // Sanitize HTML input
    .use(rehypeStringify) // Convert AST into serialized HTML
    .process('Hello, Next.js!')

  console.log(String(file)) // <p>Hello, Next.js!</p>
}

remarkrehype 생태계에는 구문 강조(syntax highlighting), 제목에 링크 달기(linking headings), 목차 생성하기(generating a table of contents) 등 다양한 플러그인들이 준비되어 있어요.

앞서 보여드린 것처럼 @next/mdx를 사용할 때는 여러분이 직접 remarkrehype를 사용할 필요가 없습니다. 내부적으로 알아서 다 처리해 주니까요. 여기서 설명해 드리는 이유는 그저 @next/mdx 패키지가 보이지 않는 곳에서 어떤 일을 하고 있는지 더 깊게 이해하시길 바라는 마음에서예요.

💡 강사의 실무 팁:
구문 강조, 즉 코드 블록에 예쁜 색상을 입히는 작업은 프론트엔드 블로그의 꽃이죠! 위 링크에 나온 rehype-pretty-code는 제가 가장 추천하는 플러그인입니다. VS Code 테마를 그대로 가져와서 적용해 주니 나중에 꼭 한 번 적용해 보세요.

Rust 기반 MDX 컴파일러 사용하기 (실험적 기능)

Next.js는 Rust로 작성된 새로운 MDX 컴파일러를 지원합니다. 이 컴파일러는 아직 실험적인 단계라서 프로덕션(실제 서비스) 환경에서 사용하는 것은 권장하지 않아요. 이 새로운 컴파일러를 사용하려면 withMDX에 전달할 때 next.config.js를 다음과 같이 설정해야 합니다:

module.exports = withMDX({
  experimental: {
    mdxRs: true,
  },
})

mdxRs는 mdx 파일을 어떻게 변환할지 설정할 수 있도록 객체를 허용하기도 합니다.

module.exports = withMDX({
  experimental: {
    mdxRs: {
      jsxRuntime?: string            // Custom jsx runtime
      jsxImportSource?: string       // Custom jsx import source,
      mdxType?: 'gfm' | 'commonmark' // Configure what kind of mdx syntax will be used to parse & transform
    },
  },
})

모든 문서의 구조적인 개요를 보고 싶으시다면 https://nextjs.org/docs/sitemap.md를 확인해 보세요.

이용 가능한 모든 문서의 인덱스(목록)를 보고 싶으시다면 https://nextjs.org/docs/llms.txt를 확인해 보세요.

profile
프론트에_가까운_풀스택_개발자

0개의 댓글