Next.js App Router로 마크다운 블로그 만들기 (정적 생성)
왜 만들었나
튜토리얼 시리즈 13번째 주제로 Next.js 정적 생성을 선택했다. 이유는 단순하다. Notion이나 CMS 없이 .md 파일 하나 추가하면 포스트가 생기는 구조를 직접 만들어보고 싶었다.
핵심 요구사항은 두 가지였다.
- API 호출 없음 — 빌드 타임에 모든 페이지가 정적 HTML로 생성
- 마크다운 그대로 — frontmatter(메타데이터) + 본문을 별도 라이브러리로 파싱
기술 상세
프로젝트 구조
13-nextjs-markdown-blog/
├── app/
│ ├── layout.tsx
│ ├── page.tsx ← 포스트 목록
│ └── posts/[slug]/
│ └── page.tsx ← 포스트 상세
├── lib/
│ └── posts.ts ← 파일 읽기 유틸
└── posts/ ← .md 파일 보관
├── getting-started-with-nextjs.md
└── ...핵심 패키지
| 패키지 | 역할 |
|---|---|
gray-matter |
frontmatter 파싱 (--- 블록 → JS 객체) |
react-markdown |
마크다운 → React 컴포넌트 렌더링 |
remark-gfm |
GitHub Flavored Markdown (표, 체크박스 등) |
rehype-highlight |
코드 블록 syntax highlight (hljs 기반) |
정적 생성 vs SSR 비교
generateStaticParams (SSG) |
export const dynamic = 'force-dynamic' (SSR) |
|
|---|---|---|
| 빌드 타임 | HTML 미리 생성 | 런타임에 생성 |
| 서버 부하 | 없음 | 요청마다 발생 |
| 콘텐츠 갱신 | 재빌드 필요 | 즉시 반영 |
| 적합한 경우 | 블로그, 문서 | 실시간 데이터 |
마크다운 파일은 배포 후 바뀌지 않으므로 SSG가 맞다.
소스 코드
lib/posts.ts — 마크다운 파일 읽기
import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';
const POSTS_DIR = path.join(process.cwd(), 'posts');
export function getAllPosts(): PostMeta[] {
const files = fs.readdirSync(POSTS_DIR).filter(f => f.endsWith('.md'));
return files
.map(filename => {
const slug = filename.replace(/\.md$/, '');
const raw = fs.readFileSync(path.join(POSTS_DIR, filename), 'utf-8');
const { data } = matter(raw);
return { slug, title: data.title, date: data.date, description: data.description, tags: data.tags ?? [] };
})
.sort((a, b) => (a.date < b.date ? 1 : -1)); // date 내림차순
}
export function getPostBySlug(slug: string): Post {
const raw = fs.readFileSync(path.join(POSTS_DIR, `${slug}.md`), 'utf-8');
const { data, content } = matter(raw);
return { slug, ...data as Omit<PostMeta, 'slug'>, content };
}
fs.readdirSync는 서버에서만 실행되므로 Server Component에서 직접 호출할 수 있다. 클라이언트 번들에 fs가 포함되지 않는다.
app/posts/[slug]/page.tsx — 정적 생성 + 렌더링
// 빌드 타임에 모든 slug를 미리 생성
export async function generateStaticParams() {
return getAllSlugs().map(slug => ({ slug }));
}
// Next.js 15: params가 Promise<> 타입
export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const post = getPostBySlug(slug);
return (
<article>
<header>
<h1>{post.title}</h1>
<time>{post.date}</time>
</header>
<div className="prose">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeHighlight]}>
{post.content}
</ReactMarkdown>
</div>
</article>
);
}
frontmatter 형식
---
title: "Next.js 시작하기"
date: "2026-02-20"
description: "App Router 핵심 정리"
tags: ["Next.js", "App Router"]
---
본문 내용...
gray-matter가 --- 블록을 파싱해 data 객체로 반환한다. 나머지는 content로 넘어온다.
삽질 기록
Next.js 15: params가 Promise<>
Next.js 15부터 동적 라우트의 params가 Promise<{ slug: string }> 타입으로 바뀌었다.
// Next.js 14 이하 (이전)
export default function Page({ params }: { params: { slug: string } }) {
const { slug } = params;
// Next.js 15 (현재)
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params; // 반드시 await
}
await params를 빠뜨리면 빌드는 통과하지만 런타임에서 slug가 undefined로 뜬다. TypeScript를 쓰면 타입 오류로 잡히므로 TS 사용을 권장한다.
다중 package-lock.json 경고
모노레포 구조(상위 tutorial/ 폴더에 별도 lock 파일 없음)에서 13-nextjs-markdown-blog/ 안에 lock 파일이 생기면 Next.js가 workspace root를 잘못 감지하며 경고를 낸다.
⚠ Warning: We detected multiple lockfiles and selected the directory
of /Users/.../tutorial/package-lock.json as the root directory.빌드 자체는 문제없이 된다. 무시하거나, next.config.ts에 turbopack.root를 명시하면 사라진다.
rehype-highlight 클래스가 적용 안 되는 경우
rehype-highlight는 코드 블록에 hljs 클래스를 붙이기만 한다. CSS는 별도로 import해야 한다. CND에서 highlight.js 테마 CSS를 가져오거나, globals.css에 직접 색상을 정의한다.
/* globals.css — hljs 토큰 직접 정의 */
.hljs-keyword { color: #93c5fd; }
.hljs-string { color: #86efac; }
.hljs-comment { color: #64748b; font-style: italic; }
마무리
정적 생성 블로그의 강점은 단순함이다. DB도 없고, API도 없다. posts/ 폴더에 .md 파일 하나 넣고 배포하면 끝이다.
블로그나 개인 노트 사이트를 만들 때 Gatsby나 Hugo를 쓰는 경우가 많은데, Next.js 하나로도 충분히 커버된다. App Router + generateStaticParams 조합을 한 번 익혀두면 다른 정적 생성 케이스에도 그대로 적용할 수 있다.
기술 스택: Next.js 15 · TypeScript · Tailwind CSS · gray-matter · react-markdown · rehype-highlight
소스 코드: https://github.com/lukaPlayground/tutorial/tree/main/13-nextjs-markdown-blog
'튜토리얼 > 웹' 카테고리의 다른 글
| [Next.js] Prisma + PostgreSQL Todo 앱 만들기 (Server Actions) (0) | 2026.03.02 |
|---|---|
| [Next.js] Vercel AI SDK로 챗봇 만들기 (스트리밍, API 키 불필요) (0) | 2026.03.02 |
| [React] 무한 스크롤 구현하기 (커스텀 훅 + IntersectionObserver) (0) | 2026.03.02 |
| [React] 날씨 앱 만들기 (Open-Meteo API · API 키 불필요) (0) | 2026.03.02 |
| [React] 영화 검색 앱 만들기 (TMDB API) (0) | 2026.03.01 |