한동안 바쁜 일이 많다보니 너무 오랜만에 포스트를 작성하는 것 같습니다. 마지막에 가까운 만큼 잘 마무리 해야겠습니다. 이번 포스트부터는 실전 프로젝트에 필요한 고급 기술을 다루며, 더 전문적인 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
# npm 사용 시 npm install -D @nuxt/content # yarn 사용 시 yarn add -D @nuxt/content
그다음, 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 경로가 됩니다.
. ├── content/ │ ├── blog/ <-- /blog │ │ ├── first-post.md <-- /blog/first-post │ │ └── second-post.md <-- /blog/second-post │ └── about.md <-- /about ├── pages/ │ └── ... └── nuxt.config.ts
마크다운 Front-Matter
마크다운 파일의 상단에 ---로 감싸인 영역(Front-Matter)을 사용하여 제목, 설명, 날짜 등의 메타데이터를 정의합니다.
content/blog/first-post.md
Markdown
--- title: '나의 첫 포스트' description: 'Nuxt Content v2로 만든 첫 포스트입니다.' date: '2025-11-08' tags: ['nuxt', 'blog', 'markdown'] --- # 포스트 본문 이곳에 마크다운 컨텐츠를 작성합니다.
Nuxt Content API 활용 예제
v2에서는 데이터를 가져오는 두 가지 핵심 방법이 있습니다.
<ContentDoc />(컴포넌트): 현재 URL과 일치하는 문서를 자동으로 찾아 렌더링합니다. (주로 상세 페이지용)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 버전 대의 포스트를 추가로 다뤄보도록 하겠습니다.






답글 남기기