[Nuxt.js-13] Nuxt 3 + Nuxt Content v2: 완벽한 마크다운 블로그 만들기 (Composition API 가이드)

  • 카카오톡 공유하기
  • 네이버 블로그 공유하기
  • 네이버 밴드에 공유하기
  • 페이스북 공유하기
  • 트위터 공유하기
  • 링크 복사하기

한동안 바쁜 일이 많다보니 너무 오랜만에 포스트를 작성하는 것 같습니다. 마지막에 가까운 만큼 잘 마무리 해야겠습니다. 이번 포스트부터는 실전 프로젝트에 필요한 고급 기술을 다루며, 더 전문적인 Nuxt.js 애플리케이션을 만드는 방법을 알아보겠습니다. 오늘은 Nuxt Content를 활용하여 블로그, 문서 사이트 등 콘텐츠 중심 웹사이트를 효율적으로 제작하는 방법을 알아보겠습니다.

Nuxt Content는 content/ 디렉토리에 마크다운 파일을 넣는 것만으로 강력한 API를 제공하여 “Git 기반 Headless CMS”처럼 사용할 수 있게 해주는 놀라운 모듈입니다.

이 포스트에서는 Nuxt 3의 Composition API (<script setup>)와 @nuxt/content v2 (v2.13.x 기준)를 사용하여, 설치부터 검색 기능 구현까지 완전한 블로그를 구축하는 모든 과정을 다룹니다.


Nuxt Content란 무엇인가?

Nuxt Content는 Nuxt.js 프로젝트의 content/ 디렉토리 안에 있는 .md, .json, .yml, .csv 파일들을 읽어들여, 마치 데이터베이스처럼 쿼리할 수 있는 API를 제공하는 모듈입니다.

  • Git-based Headless CMS: 코드가 Git으로 관리된다면, 컨텐츠(마크다운 파일) 역시 Git으로 함께 관리할 수 있습니다.
  • 빠른 속도: 정적 사이트 생성(SSG)과 완벽하게 호환되며, 서버 사이드 렌더링(SSR) 시에도 매우 빠릅니다.
  • MDC (Markdown Components): 마크다운 파일 안에서 Vue 컴포넌트를 직접 사용할 수 있는 강력한 기능을 제공합니다.

설치 및 설정

먼저 Nuxt 3 프로젝트에 @nuxt/content 모듈을 설치합니다.

Bash

그다음, nuxt.config.ts 파일에 모듈을 추가합니다.

TypeScript

// nuxt.config.ts
export default defineNuxtConfig({
  modules: [
    '@nuxt/content'
  ],
  content: {
    // (선택 사항) 코드 블록 하이라이팅 설정
    highlight: {
      theme: 'github-dark' // 'github-dark', 'github-light' 등
    }
  }
})

npm run dev로 서버를 재시작하면 모듈이 활성화됩니다.


Content 디렉토리 구조

Nuxt Content는 프로젝트 루트의 content/ 디렉토리를 기준으로 작동합니다. 이 디렉토리 구조가 그대로 URL 경로가 됩니다.

마크다운 Front-Matter

마크다운 파일의 상단에 ---로 감싸인 영역(Front-Matter)을 사용하여 제목, 설명, 날짜 등의 메타데이터를 정의합니다.

content/blog/first-post.md

Markdown


Nuxt Content API 활용 예제

v2에서는 데이터를 가져오는 두 가지 핵심 방법이 있습니다.

  1. <ContentDoc /> (컴포넌트): 현재 URL과 일치하는 문서를 자동으로 찾아 렌더링합니다. (주로 상세 페이지용)
  2. queryContent() (Composable): 원하는 조건으로 컨텐츠를 쿼리(Query)합니다. (주로 목록 페이지용)

상세 페이지: <ContentDoc />

가장 간단한 방법입니다. pages/blog/[...slug].vue 파일을 만들고 이 컴포넌트 하나만 넣으면, Nuxt Content가 content/blog/에서 일치하는 마크다운을 찾아 렌더링합니다.

pages/blog/[...slug].vue

코드 스니펫

<template>
  <main>
    <ContentDoc />
  </main>
</template>

목록 페이지: queryContent()

블로그 목록 페이지처럼 여러 문서를 가져와야 할 때는 queryContent Composable과 useAsyncData를 함께 사용합니다.

pages/blog/index.vue

코드 스니펫

<template>
  <div>
    <h1>블로그</h1>
    <ul v-if="posts && posts.length > 0">
      <li v-for="post in posts" :key="post._path">
        <h2>
          <NuxtLink :to="post._path">{{ post.title }}</NuxtLink>
        </h2>
        <p>{{ post.description }}</p>
        <small>{{ new Date(post.date).toLocaleDateString() }}</small>
      </li>
    </ul>
    <p v-else>포스트가 없습니다.</p>
  </div>
</template>

<script setup>
// useAsyncData를 사용하여 content/blog 디렉토리의 문서를 비동기로 가져옵니다.
const { data: posts } = await useAsyncData('blog-posts', () => 
  queryContent('/blog')   // /blog 디렉토리 하위
    .sort({ date: -1 }) // 날짜 내림차순 정렬
    .find()             // 모든 문서 찾기
);
</script>

콘텐츠 필터링: 쿼리 빌더 활용

queryContent()는 강력한 쿼리 빌더 체인(Chain)을 제공합니다.

JavaScript

// 'blog' 디렉토리에서
queryContent('/blog')

  // 'tags' 필드에 'nuxt'를 포함하는 문서만
  .where({ tags: { $contains: 'nuxt' } })

  // 'date' 필드로 내림차순 정렬
  .sort({ date: -1 }) 

  // 5개만 가져오기
  .limit(5) 

  // 'title'과 '_path' 필드만 선택
  .only(['title', '_path']) 

  // 최종 실행
  .find() 
  • where(filter): 조건 필터링 ($contains, $in, $ne 등)
  • sort(options): 정렬 (1: 오름차순, -1: 내림차순)
  • limit(n) / skip(n): 페이지네이션
  • only(keys) / without(keys): 특정 필드만 선택/제외
  • findOne(): 조건에 맞는 1개의 문서만 가져옵니다. (상세 페이지에서 수동으로 가져올 때 유용)

마크다운 렌더링 및 스타일링

<ContentDoc />는 문서를 자동으로 렌더링하지만, 템플릿을 세밀하게 제어하고 싶을 때가 있습니다. (예: 제목, 설명은 <head>에 넣고 본문만 렌더링)

이때 <ContentDoc />의 **v-slot**과 <ContentRenderer />를 사용합니다.

pages/blog/[...slug].vue (고급 템플릿)

코드 스니펫

<template>
  <main>
    <ContentDoc v-slot="{ doc }">
      <article>
        <h1>{{ doc.title }}</h1>
        <p>{{ doc.description }}</p>
        
        <ContentRenderer :value="doc" />
      </article>
    </ContentDoc>
  </main>
</template>

스타일링

Nuxt Content는 렌더링된 HTML에 스타일을 적용하지 않습니다. 스타일은 직접 작성해야 합니다.

CSS

/* <ContentDoc /> 또는 <ContentRenderer />는 
 렌더링된 컨텐츠를 <div class="prose"> (설정에 따라 다름)로 감쌀 수 있습니다.
 이 클래스를 타겟팅하여 스타일을 적용하는 것이 좋습니다.
*/
.prose h1 {
  font-size: 2.5rem;
  margin-bottom: 1rem;
}
.prose p {
  line-height: 1.7;
  margin-bottom: 1rem;
}
.prose pre {
  background-color: #f4f4f4;
  padding: 1rem;
  border-radius: 8px;
}

팁: @tailwindcss/typography 플러그인을 사용하면 .prose 클래스 하나로 아름다운 마크다운 스타일을 즉시 적용할 수 있습니다.


포스트 검색 기능 구현

@nuxt/content v2.13.x 기준, searchContent Composable을 사용하여 매우 쉽게 검색 기능을 구현할 수 있습니다. (v2.13 이전 버전에서는 queryContent().search()를 사용해야 합니다.)

코드 스니펫

<template>
  <div>
    <input 
      type="text" 
      v-model="query" 
      placeholder="검색..." 
      @input="searchPosts"
    />
    
    <ul v-if="results">
      <li v-for="result in results" :key="result._path">
        <NuxtLink :to="result._path">{{ result.title }}</NuxtLink>
      </li>
    </ul>
  </div>
</template>

<script setup>
import { ref } from 'vue';

const query = ref('');
const results = ref(null);

// searchContent는 v2.13.0+ 부터 사용 가능
const searchPosts = async () => {
  if (!query.value) {
    results.value = null;
    return;
  }
  // 쿼리를 기반으로 제목(title)과 설명(description)에서 검색
  results.value = await searchContent(query, {
    // options: {
    //   fields: ['title', 'description', 'tags'], // 검색할 필드 지정
    //   ...
    // }
  });
};
</script>

블로그 예시 (TOC 및 이전/다음 포스트)

모든 기능을 조합하여 상세 페이지([...slug].vue)를 완성해 봅시다.

pages/blog/[...slug].vue

코드 스니펫

<template>
  <main class="blog-post-page">
    <ContentDoc v-slot="{ doc }">
      <h1>{{ doc.title }}</h1>
      <p>{{ doc.description }}</p>
      <small>작성일: {{ doc.date }}</small>

      <hr>

      <aside v-if="doc.body && doc.body.toc && doc.body.toc.links.length > 0">
        <h3>목차</h3>
        <ul>
          <li v-for="link in doc.body.toc.links" :key="link.text">
            <a :href="`#${link.id}`">{{ link.text }}</a>
          </li>
        </ul>
      </aside>

      <hr>

      <ContentRenderer :value="doc" class="prose" />

      <nav class="post-navigation">
        <NuxtLink v-if="prev" :to="prev._path">
          <span>← 이전 글</span>
          <p>{{ prev.title }}</p>
        </NuxtLink>
        <span v-else></span> <NuxtLink v-if="next" :to="next._path">
          <span>다음 글 →</span>
          <p>{{ next.title }}</p>
        </NuxtLink>
      </nav>
    </ContentDoc>
  </main>
</template>

<script setup>
// 4. 이전/다음 포스트 데이터를 가져오기 위해 useContentSurround 사용
const { data } = await useAsyncData('surround', () => 
  queryContent('/blog')
    .only(['_path', 'title']) // 필요한 필드만
    .sort({ date: 1 })        // 날짜 오름차순 (그래야 이전/다음이 맞음)
    .findSurround(useRoute().path) // 현재 경로를 기준으로 앞뒤 찾기
);

// data.value는 [prev, next] 형태의 배열
const prev = computed(() => data.value?.[0]);
const next = computed(() => data.value?.[1]);

// 1. 메타데이터 (SEO) 설정
// useContentHead(doc)을 <ContentDoc> 슬롯 밖에서 사용하려면
// 수동으로 쿼리해야 합니다. (useContentSurround 예제와 병합 가능)
// 혹은 <ContentDoc>의 :head="false" 처리 후 수동 설정
</script>

<style scoped>
.post-navigation {
  display: flex;
  justify-content: space-between;
  margin-top: 2rem;
  border-top: 1px solid #eee;
  padding-top: 1rem;
}
.post-navigation a {
  text-decoration: none;
  color: #333;
}
.post-navigation a:hover p {
  color: #007bff;
}
.post-navigation a span {
  font-size: 0.9rem;
  color: #777;
}
.post-navigation a p {
  font-weight: 500;
  margin: 0.25rem 0 0 0;
}
</style>

마치며

Nuxt Content v2는 Nuxt 3와 완벽하게 통합되어, Composition API와 함께 매우 직관적이고 강력한 개발 경험을 제공합니다. queryContent()의 강력한 쿼리 기능과 <ContentDoc />의 편리함을 활용하여 여러분만의 멋진 마크다운 기반 블로그나 문서를 구축해 보세요!

이 가이드를 바탕으로 더 복잡한 기능(태그별 필터링, 컴포넌트 내 마크다운 사용 등)에 도전해 보시는 것도 좋겠습니다.

최근 @nuxt/content 3.x 버전 부터는 새로운 API 규격이 나왔습니다. 다음 기회에 3 버전 대의 포스트를 추가로 다뤄보도록 하겠습니다.


게시됨

카테고리

작성자

댓글

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다