본문 바로가기
튜토리얼/웹

[Next.js] 마크다운 블로그 만들기 (정적 생성)

by 루까(Luka) 2026. 3. 2.
반응형

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: paramsPromise<>

Next.js 15부터 동적 라우트의 paramsPromise<{ 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.tsturbopack.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

데모: https://tutorial-nextjs-markdown-blog.vercel.app

반응형