Next.js 블로그 만들기: CMS 없이 마크다운 파일과 Shiki 코드 하이라이트로 정적 빌드한 기록
Next.js 블로그 만들기를 CMS 없이 .md 파일과 git만으로 한 기록입니다. unified 파이프라인에 rehype-pretty-code(Shiki)를 붙이고 빌드 타임에 전부 구웠습니다.
- Next.js
- 블로그
- 제품 개발
목차
지금 읽고 계신 이 블로그가 그 결과물입니다. Next.js 16 App Router 위에서 .md 파일을 읽어 정적 페이지로 굽고, 코드 블록은 Shiki로 하이라이트합니다. CMS도 없고 데이터베이스도 없습니다. 이 글은 그 파이프라인을 만들면서 선택한 것과 버린 것의 기록입니다.
git과 마크다운만으로 충분했습니다#
블로그 하나 열자고 headless CMS를 붙이는 건 과했습니다. 글은 어차피 에디터에서 마크다운으로 쓰고, git이 이미 버전 관리를 해 줍니다. 그래서 구조를 이렇게 정했습니다.
- 글은
src/content/blog/*.md에 두고, 파일 이름이 곧 slug - 메타데이터는 프론트매터에 두고 gray-matter로 파싱
- 읽기 시간은 reading-time으로 빌드 시점에 계산
- 렌더링은 서버 컴포넌트에서 HTML 문자열로 변환
글을 추가하는 절차가 "파일 하나 만들고 push"로 끝납니다. 관리 화면도, 로그인도, API 키도 없습니다.
gray-matter로 파일을 글로 바꾸기#
핵심 로직은 src/lib/blog.ts 하나입니다. 파일을 읽고, 프론트매터와 본문을 분리하고, 타입을 입힙니다.
export function getPostBySlug(slug: string): BlogPost | null {
const fullPath = path.join(BLOG_DIR, `${slug}.md`);
if (!fs.existsSync(fullPath)) return null;
const raw = fs.readFileSync(fullPath, "utf8");
const { data, content } = matter(raw);
const stats = readingTime(content);
return {
slug,
title: String(data.title ?? slug),
excerpt: String(data.excerpt ?? ""),
date: String(data.date ?? ""),
tags: Array.isArray(data.tags) ? data.tags.map(String) : [],
content,
readingMinutes: Math.max(1, Math.ceil(stats.minutes)),
};
}작은 디테일이지만 프론트매터 값마다 String(...) 폴백을 걸어 뒀습니다. YAML은 date: 2026-07-12를 문자열이 아니라 Date 객체로 파싱하는 식으로 타입이 흔들리기 쉽습니다. 프론트매터 하나 잘못 쓰면 빌드가 깨지는 블로그는 만들고 싶지 않았습니다. readingMinutes도 Math.max(1, ...)로 최소 1분을 보장합니다. "0분 읽기"가 화면에 뜨는 건 사소하지만 없어 보입니다.
렌더링은 react-markdown 대신 unified 직접 조립#
처음 계획은 react-markdown이었습니다. 컴포넌트 하나 얹으면 끝이니까요. 그런데 코드 하이라이트를 붙이려고 보니, 어차피 내부는 unified 파이프라인이고 플러그인을 세밀하게 제어하려면 직접 조립하는 쪽이 명확했습니다. 서버 컴포넌트에서 HTML 문자열을 만들어 버리면 클라이언트로 내려갈 JS도 없습니다. 지금도 package.json에 react-markdown이 남아 있는데, 실제 렌더 경로에서는 쓰지 않습니다.
최종 파이프라인은 MarkdownBody.tsx에 있습니다.
async function renderMarkdown(content: string) {
const file = await unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkRehype)
.use(rehypePrettyCode, prettyCodeOptions)
.use(rehypeExternalLinks, {
target: "_blank",
rel: ["noopener", "noreferrer"],
})
.use(rehypeStringify)
.process(content);
return String(file);
}순서대로 읽으면 그대로 동작 설명이 됩니다.
- remark-parse: 마크다운 텍스트를 AST로
- remark-gfm: 테이블, 취소선 같은 GitHub 문법 지원
- remark-rehype: 마크다운 AST를 HTML AST로 변환
- rehype-pretty-code: 코드 블록을 Shiki로 하이라이트
- rehype-external-links: 외부 링크에
target="_blank"+noopener자동 부여 - rehype-stringify: HTML 문자열로 직렬화
결과물은 dangerouslySetInnerHTML로 꽂습니다. 이름이 무섭지만, 이 블로그의 마크다운은 전부 저장소 안에서 제가 쓰는 파일이지 사용자 입력이 아닙니다. 신뢰 경계가 다르면 판단도 달라집니다. 대신 코드에 그 전제를 주석으로 박아 뒀습니다. 나중에 댓글 같은 사용자 입력을 이 컴포넌트에 흘리면 안 된다는 표시입니다.
Shiki 다크 테마와 keepBackground 함정#
rehype-pretty-code는 Shiki의 래퍼입니다. 정규식 기반 하이라이터와 달리 Shiki는 VS Code의 TextMate 문법을 그대로 쓰기 때문에 결과가 에디터에서 보던 색 그대로 나옵니다. 빌드 시점에 다 계산되니 클라이언트에 하이라이트 JS를 내려보낼 필요도 없습니다.
옵션은 세 줄인데, 한 줄이 시행착오의 산물입니다.
const prettyCodeOptions = {
theme: "github-dark" as const,
keepBackground: false,
defaultLang: "txt",
};theme: "github-dark". 이 블로그는 다크 톤이라 테마 하나로 통일했습니다.keepBackground: false. 여기서 걸렸습니다. Shiki는 기본적으로 테마의 배경색(#24292e)을pre에 인라인 스타일로 박아 버립니다. 문제는 이 색이 페이지 배경과 미묘하게 달라서, 코드 블록이 본문 위에 붙인 스티커처럼 떠 보였습니다. 배경은 CSS 쪽에서 직접 소유하도록 끄고, 코드 블록 컨테이너 스타일은 전역 CSS가 담당하게 했습니다. 인라인 스타일은 CSS로 이길 수 없으니 소스에서 끄는 게 맞습니다.defaultLang: "txt". 언어를 안 적은 코드 펜스가 하나라도 있으면 빌드가 불평합니다. 디렉토리 구조 같은 걸 펜스에 넣는 일이 많아서 기본값을 깔았습니다.
generateStaticParams로 전부 빌드 타임에 굽기#
글이 파일 시스템에 있으니 목록도 빌드 타임에 확정됩니다. generateStaticParams에 slug 목록을 넘기면 Next.js가 모든 글을 정적 HTML로 미리 생성합니다.
export function generateStaticParams() {
return getAllPosts().map((post) => ({ slug: post.slug }));
}
export default async function BlogPostPage({ params }: Props) {
const { slug } = await params;
const post = getPostBySlug(slug);
if (!post) notFound();
// ...
}한 가지, Next.js 15부터 params가 Promise로 바뀌었습니다. 예전 코드처럼 params.slug를 바로 꺼내면 안 되고 await params를 거쳐야 합니다. 마이그레이션 글에서 보고 아는 것과 타입 에러로 직접 만나는 건 체감이 다릅니다. generateMetadata도 같은 방식으로 slug를 받아 글의 title과 excerpt를 메타 태그로 씁니다. 없는 slug는 notFound()로 404 처리합니다. 정적 생성이어도 이 방어는 필요합니다.
렌더 파이프라인에서 지킨 경계#
- 블로그에 CMS는 대부분 과투자다. 글쓴이가 개발자 한 명이면 git + .md가 이미 CMS다.
- 하이라이트를 커스터마이즈할 거면 react-markdown보다 unified 직접 조립이 낫다. 추상화 한 겹 아래가 어차피 같은 물건이다.
- Shiki의 인라인 배경색은 끄고 시작하는 게 낫다. 페이지 배경과 싸우게 되는 순간 CSS로는 못 이긴다.
- defaultLang 같은 방어 옵션은 미리 깔아 둔다. 글 쓸 때마다 파이프라인을 신경 쓰게 만들면 글을 안 쓰게 된다.
전체 구성 요소는 파일 세 개입니다. blog.ts(읽기), MarkdownBody.tsx(렌더), page.tsx(정적 생성). 이 정도 두께면 1년 뒤에 열어 봐도 10분 안에 다시 파악할 수 있습니다. 블로그 인프라는 그 정도가 적당하다고 봅니다.