Content4분 읽기

Notion을 블로그 CMS로 쓰는 법 — Next.js 완전 연동

Notion DB를 블로그 CMS로 연결하는 전체 과정. @notionhq/client와 notion-to-md로 실제 운영 중인 구조를 코드 기반으로 설명합니다.

#Notion#Next.js#CMS#블로그 자동화
Notion을 블로그 CMS로 쓰는 법 — Next.js 완전 연동

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 발급

  1. notion.so/profile/integrations 접속
  2. "새 integration" 생성 → NOTION_TOKEN 복사
  3. 연결할 Notion DB 페이지 → "연결 추가" → 방금 만든 integration 선택
  4. 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 글과 연결 예정]

공유하기Twitter / X