Notion을 블로그 CMS로 쓰는 법 — Next.js 완전 연동
Notion DB를 블로그 CMS로 연결하는 전체 과정. @notionhq/client와 notion-to-md로 실제 운영 중인 구조를 코드 기반으로 설명합니다.
TL;DR
- Notion DB 하나를 만들면 글 관리 인터페이스가 공짜로 생긴다
@notionhq/client+notion-to-md두 패키지면 Next.js 연동 완성- ISR(Incremental Static Regeneration)로 빌드 없이 새 글이 자동 반영된다
왜 Notion인가
Headless CMS 옵션은 많다. Contentful, Sanity, Strapi — 전부 좋은 툴이다. 그런데 이미 Notion으로 글을 쓰고 있다면 CMS를 따로 배울 이유가 없다.
Notion CMS의 실제 장점:
- 글 편집기가 이미 익숙하다 — 마크다운보다 편한 블록 에디터
- Database 뷰 — 발행 전/후, 카테고리별 필터를 자유롭게 구성
- 모바일 앱 — 이동 중 초안 작성, Published 체크박스 하나로 즉시 공개
단점도 있다. Notion API 이미지 URL은 1시간 후 만료된다. 본문 내 이미지를 많이 쓴다면 별도 처리가 필요하다. 커버 이미지는 외부 URL(Unsplash 등)을 쓰면 이 문제를 피할 수 있다.
준비물
@notionhq/client — 공식 Notion API 클라이언트
notion-to-md — Notion 블록 → Markdown 변환
npm install @notionhq/client notion-to-md
Notion Integration 발급
- notion.so/profile/integrations 접속
- "새 integration" 생성 →
NOTION_TOKEN복사 - 연결할 Notion DB 페이지 → "연결 추가" → 방금 만든 integration 선택
- DB URL에서 ID 추출 →
NOTION_DATABASE_ID
https://notion.so/workspace/[DATABASE_ID]?v=...
Notion DB 스키마 설정
DB에 아래 속성을 추가한다. 타입을 정확히 맞춰야 코드와 연동된다.
| 속성명 | 타입 | 설명 |
|---|---|---|
| Title | 제목 | 글 제목 |
| Slug | 텍스트 | URL 경로 (영소문자, 하이픈) |
| Date | 날짜 | 발행일 |
| Category | 선택 | AI Tools / App Dev / Content |
| Tags | 다중 선택 | 관련 태그 |
| Description | 텍스트 | 메타 설명 (120~160자) |
| Cover | 파일 & 미디어 | 커버 이미지 URL |
| Published | 체크박스 | true일 때만 공개 |
undefined
Next.js 연동 코드
lib/notion.ts
import { Client } from "@notionhq/client";
import { NotionToMarkdown } from "notion-to-md";
const notion = new Client({ auth: process.env.NOTION_TOKEN });
const n2m = new NotionToMarkdown({ notionClient: notion });
export async function getBlogPosts() {
const response = await notion.databases.query({
database_id: process.env.NOTION_DATABASE_ID!,
filter: { property: "Published", checkbox: { equals: true } },
sorts: [{ property: "Date", direction: "descending" }],
});
return response.results.map(extractPost);
}
export async function getBlogPost(slug: string) {
const response = await notion.databases.query({
database_id: process.env.NOTION_DATABASE_ID!,
filter: {
and: [
{ property: "Published", checkbox: { equals: true } },
{ property: "Slug", rich_text: { equals: slug } },
],
},
});
const page = response.results[0];
const mdBlocks = await n2m.pageToMarkdown(page.id);
const content = n2m.toMarkdownString(mdBlocks).parent;
return { ...extractPost(page), content };
}
extractPost는 Notion page 객체에서 각 속성을 꺼내는 함수다. Notion API는 속성 타입마다 구조가 다르기 때문에 타입별로 접근 방식이 다르다 — title은 배열, select는 객체, multi_select는 배열이다.
app/blog/[slug]/page.tsx
export const revalidate = 3600; // 1시간마다 ISR 재생성
export async function generateStaticParams() {
const posts = await getBlogPosts();
return posts.map((p) => ({ slug: p.slug }));
}
revalidate = 3600 한 줄이 핵심이다. 빌드 시 정적 페이지를 만들면서, 1시간이 지나면 백그라운드에서 새로 생성한다. Notion에서 글을 수정하면 최대 1시간 안에 반영된다.
즉시 반영이 필요하다면 Vercel의 On-demand Revalidation API를 쓰면 된다.
curl -X POST https://moonyth.app/api/revalidate \
-H "Content-Type: application/json" \
-d '{"secret": "YOUR_TOKEN", "path": "/blog"}'
주의해야 할 것들
이미지 URL 만료
Notion이 호스팅하는 이미지(업로드된 파일)는 API 응답 시점에서 1시간 후 만료된다. 본문 이미지를 많이 쓴다면 글 작성 시 Unsplash 외부 URL을 사용하거나, 이미지를 R2/S3에 별도 업로드하는 파이프라인이 필요하다.
API Rate Limit
Notion API는 평균 초당 3요청 제한이 있다. 글이 많아지면 generateStaticParams에서 병렬 요청을 제한해야 한다.
Published 체크박스 = 배포 스위치
Notion에서 Published를 체크하는 순간부터 ISR 주기 이내에 공개된다. 초안 상태로 두려면 항상 체크 해제 상태로 작성한다.
마무리
Notion CMS 연동은 생각보다 단순하다. 패키지 두 개, 환경 변수 두 개, 함수 두 개면 끝난다.
글쓰기 도구는 이미 Notion을 쓰고 있다면, CMS를 따로 배울 이유가 없다. 지금 쓰는 Notion DB에 속성 몇 개 추가하고, 코드 연결하면 된다.
[내부링크 추천: /blog/notion-blog-cms-nextjs 완성 후 /blog/claude-blog-agent 글과 연결 예정]