Contentlayer 없이 MDX 블로그 만들기: 직접 파서 짠 후기
2026.04.21Contentlayer가 사라진 자리
이 블로그를 처음 만들 때 Contentlayer를 쓸까 했다. MDX 파싱, frontmatter 파싱, 타입 생성을 한 번에 해주는 도구다. 검색하면 다 Contentlayer를 쓴다.
문제는 Contentlayer가 메인테넌스 중단 상태라는 거다. Next.js 15 호환에 이슈가 있고, App Router 이슈도 적극적으로 안 고쳐지고 있다. Contentlayer 2가 나온다고 했지만 일정이 명확하지 않다.
대안은 두 가지다. fork된 community 버전을 쓰거나, 직접 짠다. 내가 고른 건 후자다. 결과적으로 100줄 안쪽의 자체 콘텐츠 레이어가 됐고, 더 자유롭고 더 빠르다.
Contentlayer가 해주던 일
직접 짜기 전에 Contentlayer가 정확히 어떤 일을 해주는지 정리.
content/디렉토리의 MDX 파일을 스캔- 각 파일의 frontmatter를 파싱
- 본문을 미리 컴파일된 React 컴포넌트로 변환
- 모든 파일을 모은 인덱스를 빌드 타임에 생성
- TypeScript 타입을 자동 생성
이 다섯 가지를 직접 하면 된다.
짠 코드
핵심 파일은 lib/posts.ts 하나다.
import fs from "node:fs";
import path from "node:path";
import matter from "gray-matter";
const CONTENT_DIR = path.join(process.cwd(), "content");
export type Post = {
slug: string;
title: string;
description: string;
date: string;
tags: string[];
content: string;
};
export function getAllPosts(): Post[] {
const dirs = fs.readdirSync(CONTENT_DIR);
const posts: Post[] = [];
for (const dir of dirs) {
const dirPath = path.join(CONTENT_DIR, dir);
if (!fs.statSync(dirPath).isDirectory()) continue;
const files = fs.readdirSync(dirPath).filter((f) => f.endsWith(".mdx"));
for (const file of files) {
const raw = fs.readFileSync(path.join(dirPath, file), "utf-8");
const { data, content } = matter(raw);
posts.push({
slug: file.replace(/\.mdx$/, ""),
title: data.title,
description: data.description,
date: data.date,
tags: data.tags || [],
content,
});
}
}
return posts.sort((a, b) => b.date.localeCompare(a.date));
}
export function getPostBySlug(slug: string): Post | null {
return getAllPosts().find((p) => p.slug === slug) || null;
}
export function getPostsByCategory(category: string): Post[] {
return getAllPosts().filter((p) => p.slug.includes(category));
}50줄도 안 된다. 이걸로 콘텐츠 인덱스 1번~5번을 다 한다.
페이지에서 쓰는 법
블로그 목록 페이지.
// app/blog/page.tsx
import { getAllPosts } from "@/lib/posts";
export default function BlogIndex() {
const posts = getAllPosts();
return (
<ul>
{posts.map((p) => (
<li key={p.slug}>
<a href={`/blog/${p.slug}`}>{p.title}</a>
<time>{p.date}</time>
</li>
))}
</ul>
);
}개별 글 페이지.
// app/blog/[slug]/page.tsx
import { MDXRemote } from "next-mdx-remote/rsc";
import { getAllPosts, getPostBySlug } from "@/lib/posts";
export async function generateStaticParams() {
return getAllPosts().map((p) => ({ slug: p.slug }));
}
export default async function PostPage({ params }) {
const post = getPostBySlug(params.slug);
if (!post) return null;
return (
<article>
<h1>{post.title}</h1>
<MDXRemote source={post.content} />
</article>
);
}App Router MDX 글에서 다룬 패턴 그대로.
Contentlayer와 비교
직접 짠 후 한 달 이상 운영해본 결과.
장점
1. 빌드 속도
Contentlayer는 빌드 시 .contentlayer/ 디렉토리를 만들면서 모든 글을 한 번 컴파일한다. 글이 100개 넘어가면 무거워진다. 직접 짠 버전은 그런 사전 컴파일 단계가 없다. 페이지가 SSG될 때 한 번만 처리한다.
2. 자유도
frontmatter 형식, 디렉토리 구조, slug 규칙을 다 마음대로 정한다. Contentlayer는 설정으로만 바꿀 수 있다.
3. 의존성이 줄어든다
gray-matter 한 개만 추가된다. Contentlayer는 자체적으로 여러 의존성을 가진다.
4. 디버깅이 쉽다
문제가 생기면 내 코드를 본다. Contentlayer는 black-box라서 빌드 에러가 나면 lib 내부를 까봐야 한다.
단점
1. 타입 자동 생성이 없다
Contentlayer는 frontmatter 스키마에서 TypeScript 타입을 자동 생성한다. 직접 짠 버전은 type Post를 수동으로 적어야 한다. 다만 이건 한 번만 적으면 끝이다.
2. 검증이 약하다
Contentlayer는 frontmatter에 필수 필드가 빠지면 빌드 에러를 낸다. 직접 짠 버전은 런타임에 undefined를 만난다. zod로 검증층을 추가하면 비슷하게 만들 수 있다.
import { z } from "zod";
const PostSchema = z.object({
title: z.string(),
description: z.string(),
date: z.string(),
tags: z.array(z.string()).optional(),
});
export function getAllPosts(): Post[] {
// ...
for (const file of files) {
const { data, content } = matter(raw);
const parsed = PostSchema.parse(data); // 검증 + 타입 추론
posts.push({ ...parsed, content, slug: ... });
}
}이걸 추가하면 frontmatter 누락 시 빌드 에러로 잡힌다.
빌드 캐시 추가
콘텐츠가 많아지면 빌드 시 매번 fs.readFileSync 수십 번이 일어난다. 빌드 캐시를 붙이면 변경된 파일만 다시 파싱한다.
// lib/posts.ts에 추가
let cache: Post[] | null = null;
let cacheKey: string | null = null;
export function getAllPosts(): Post[] {
const dirs = fs.readdirSync(CONTENT_DIR);
const key = dirs.map((d) => fs.statSync(path.join(CONTENT_DIR, d)).mtimeMs).join("-");
if (cache && cacheKey === key) return cache;
const posts = computePosts(); // 위의 원래 로직
cache = posts;
cacheKey = key;
return posts;
}빌드 한 번 안에서 같은 글을 여러 페이지가 부르면 한 번만 파싱한다. 빌드 시간이 절반쯤 줄었다. Cloudflare 빌드 캐시 글에서 더 다뤘다.
카테고리 분리
이 블로그는 content/dev/와 content/life/ 두 카테고리로 분리되어 있다. 직접 짠 코드는 폴더 구조를 자유롭게 받는다.
export function getAllPosts(): Post[] {
// ...
}
export function getDevPosts(): Post[] {
return getAllPosts().filter((p) => p.category === "dev");
}
export function getLifePosts(): Post[] {
return getAllPosts().filter((p) => p.category === "life");
}폴더 이름을 카테고리로 쓰니까 추가 메타데이터 없이 분리된다.
흔한 함정
함정 1: frontmatter 타입 안전성
data.title은 자동으로 any다. zod 검증을 안 하면 빌드는 통과해도 런타임에 깨진다. 처음에는 zod 없이 시작하더라도, 글이 10개 넘으면 추가하는 게 안전하다.
함정 2: MDX 컴포넌트 import
MDX 안에서 <GitCommandCard /> 같은 컴포넌트를 쓰려면 MDXRemote에 components prop으로 전달해야 한다.
import GitCommandCard from "@/components/GitCommandCard";
<MDXRemote
source={post.content}
components={{ GitCommandCard }}
/>이걸 빠뜨리면 MDX 안의 컴포넌트가 그대로 텍스트로 렌더된다.
함정 3: 정렬
localeCompare로 정렬하면 한국어 제목도 자연스럽게 정렬된다. 단순 비교(a > b)는 유니코드 코드포인트 기준이라 한국어 정렬이 어색해진다.
정리
Contentlayer 없이 MDX 블로그를 만드는 건 어렵지 않다. gray-matter + 직접 짠 50줄짜리 인덱스 함수가 본질이다.
직접 짜면 의존성이 가벼워지고, 자유도가 높아지고, 디버깅이 쉽다. 단점인 타입 자동 생성과 검증은 zod로 보완할 수 있다.
내 결론은 단순하다. 콘텐츠가 100개 미만이고, 형식이 자주 변하지 않는 개인 블로그라면 직접 짜는 게 답이다. Contentlayer가 살아 돌아오면 그때 가서 다시 고민해도 늦지 않다. 그때까지는 50줄짜리 코드가 충분히 잘 동작한다.