RICCILAB
> blog/nextjs-notion-cms

Next.js 블로그에 Notion CMS 연동하기

_DEV

왜 Notion CMS인가

Next.js 블로그를 운영하다 보면 글 하나 쓸 때마다 .mdx 파일을 만들고, git commit, git push를 해야 하는 불편함이 있다. 네이버 블로그나 티스토리처럼 웹에서 바로 쓰고 발행할 수 없을까?

Notion을 Headless CMS로 연동하면 이 문제가 해결된다:

  • Notion에서 글 작성 → 자동으로 블로그에 반영
  • 코드 커밋/푸시 불필요
  • Notion의 강력한 에디터 활용 (수식, 코드블록, 테이블 등)
  • ISR(Incremental Static Regeneration)으로 5분 내 자동 갱신

아키텍처 개요

전체 흐름은 다음과 같다:

  1. Notion 데이터베이스에 블로그 포스트 작성
  2. Next.js 서버가 Notion API로 데이터 조회
  3. Notion 블록을 MDX 문자열로 변환
  4. next-mdx-remote/rsc로 서버사이드 렌더링
  5. ISR로 5분마다 자동 갱신 (즉시 갱신도 가능) 기존 파일시스템 기반 .mdx 포스트와 Notion 포스트가 공존하는 하이브리드 구조다.

1단계: Notion 데이터베이스 설정

Integration 생성

Notion API를 사용하려면 먼저 Integration을 만들어야 한다.

  1. Notion Developers에서 새 Integration 생성
  2. Internal Integration 선택
  3. API Key 발급 (ntn_ 으로 시작하는 키)

데이터베이스 스키마

Blog Posts 데이터베이스에 다음 속성을 추가한다:

속성타입설명
Titletitle글 제목
SlugtextURL 경로 (예: my-first-post)
Datedate작성일
Descriptiontext글 설명
Tagsmulti_select태그 목록
Publishedcheckbox발행 여부

Integration 연결

데이터베이스 페이지에서 우측 상단 → 연결 → 생성한 Integration을 추가한다. 이 단계를 빠뜨리면 API에서 데이터베이스를 찾을 수 없다.


2단계: Notion API 클라이언트

패키지 설치

npm install @notionhq/client

환경변수 설정

.env.local 파일에 다음을 추가:

NOTION_API_KEY=ntn_your_api_key_here
NOTION_BLOG_DATABASE_ID=your_data_source_id_here
REVALIDATION_SECRET=your_secret_here

주의: @notionhq/client v5 에서는 databases.query()가 제거되고 dataSources.query()로 대체되었다. 이전 버전의 코드를 그대로 쓰면 에러가 발생한다.


3단계: Notion 블록 → MDX 변환

핵심은 Notion의 블록 구조를 MDX 문자열로 변환하는 것이다. src/lib/notion.ts에 변환 로직을 구현한다.

Rich Text 변환

Notion의 rich text는 annotation(bold, italic, code 등)을 포함한다. 이를 마크다운 문법으로 변환:

function richTextToMdx(richTexts: RichTextItemResponse[]): string {
  return richTexts
    .map((rt) => {
      let text = rt.plain_text;
      // 수식 처리
      if (rt.type === "equation") {
        return `$${rt.equation.expression}$`;
      }
      // 링크 처리
      if (rt.type === "text" && rt.text.link) {
        text = `[${text}](${rt.text.link.url})`;
      }
      // 어노테이션 처리
      const a = rt.annotations;
      if (a.code) text = `\`${text}\``;
      if (a.bold) text = `**${text}**`;
      if (a.italic) text = `*${text}*`;
      if (a.strikethrough) text = `~~${text}~~`;
      return text;
    })
    .join("");
}

블록 변환

각 블록 타입별로 MDX 마크다운을 생성한다:

async function blockToMdx(block: Block, indent = ""): Promise<string> {
  switch (block.type) {
    case "paragraph":
      return `${richTextToMdx(block.paragraph.rich_text)}\n\n`;
    case "heading_1":
      return `# ${richTextToMdx(block.heading_1.rich_text)}\n\n`;
    case "heading_2":
      return `## ${richTextToMdx(block.heading_2.rich_text)}\n\n`;
    case "code": {
      const lang = block.code.language;
      const code = richTextToMdx(block.code.rich_text);
      return `\`\`\`${lang}\n${code}\n\`\`\`\n\n`;
    }
    case "equation":
      return `$$\n${block.equation.expression}\n$$\n\n`;
    case "image": {
      const url = block.image.type === "external"
        ? block.image.external.url
        : block.image.file.url;
      const caption = block.image.caption
        ? richTextToMdx(block.image.caption) : "";
      return `![${caption}](${url})\n\n`;
    }
    // ... 기타 블록 타입들
  }
}

지원하는 블록 타입: paragraph, heading 1~3, bulleted/numbered list, code, equation, image, divider, quote, callout, toggle, bookmark, table.


4단계: 데이터 조회 (캐싱 + ISR)

Next.js의 unstable_cache를 활용하여 Notion API 호출을 캐싱하고, ISR로 주기적으로 갱신한다.

import { unstable_cache } from "next/cache";
 
export const getNotionPosts = unstable_cache(
  async (): Promise<NotionBlogPost[]> => {
    const response = await notion.dataSources.query({
      data_source_id: DATABASE_ID,
      filter: {
        property: "Published",
        checkbox: { equals: true },
      },
      sorts: [{ property: "Date", direction: "descending" }],
    });
    // ... 결과 매핑
  },
  ["notion-posts"],
  { tags: ["notion-posts"], revalidate: 300 } // 5분
);

revalidate: 300으로 5분마다 자동 갱신된다.


5단계: 하이브리드 블로그 시스템

기존 파일시스템 포스트와 Notion 포스트를 함께 사용하는 하이브리드 구조:

export async function getAllPosts(): Promise<BlogPost[]> {
  const fsPosts = getFilesystemPosts();      // 로컬 .mdx 파일
  const notionPosts = await getNotionPosts(); // Notion DB
 
  // slug 충돌 시 파일시스템 우선
  const slugSet = new Set(fsPosts.map((p) => p.slug));
  const merged = [
    ...fsPosts,
    ...notionPosts.filter((p) => !slugSet.has(p.slug)),
  ];
 
  return merged.sort((a, b) => (a.date > b.date ? -1 : 1));
}

같은 slug가 있으면 파일시스템 버전이 우선이므로, 기존 포스트를 깨뜨리지 않는다.


6단계: On-Demand Revalidation API

5분을 기다리지 않고 즉시 갱신하고 싶을 때를 위한 API 엔드포인트:

// src/app/api/revalidate/route.ts
import { revalidateTag } from "next/cache";
 
export async function POST(request: NextRequest) {
  const { secret } = await request.json();
 
  if (secret !== process.env.REVALIDATION_SECRET) {
    return Response.json({ error: "Invalid secret" }, { status: 401 });
  }
 
  revalidateTag("notion-posts", "max");
  revalidateTag("notion-post", "max");
 
  return Response.json({ revalidated: true, now: Date.now() });
}

호출 방법:

curl -X POST https://your-domain.com/api/revalidate \
  -H "Content-Type: application/json" \
  -d '{"secret":"your_secret_here"}'

7단계: Vercel 배포

환경변수 설정

Vercel Dashboard → Settings → Environment Variables에서 3개 변수를 추가한다:

  • NOTION_API_KEY
  • NOTION_BLOG_DATABASE_ID
  • REVALIDATION_SECRET 반드시 Production, Preview, Development 전부 체크하고 저장 후 Redeploy 해야 한다.

이미지 호스트 허용

Notion 이미지를 사용하려면 next.config.ts에 remote pattern을 추가:

images: {
  remotePatterns: [
    {
      protocol: "https",
      hostname: "prod-files-secure.s3.us-west-2.amazonaws.com",
    },
    {
      protocol: "https",
      hostname: "www.notion.so",
    },
  ],
}

삽질 기록

연동 과정에서 만난 문제들:

databases.query is not a function

@notionhq/client v5에서 databases.query()가 제거되었다. dataSources.query()를 사용해야 한다. 파라미터도 database_id 대신 data_source_id를 쓴다.

Vercel에서 환경변수가 안 읽힘

환경변수를 설정한 후 반드시 새로 배포(Redeploy) 해야 한다. 기존 배포를 Redeploy하면 이전 커밋의 코드로 다시 빌드되므로, git push로 새 배포를 트리거하는 것이 확실하다.

next-mdx-remote/rsc에서 children이 undefined

MDX 커스텀 컴포넌트에 template literal children을 전달할 수 없다. JSX prop으로 데이터를 넘겨야 한다:

// ❌ 안 됨
<CyberChart>{data}</CyberChart>
 
// ✅ 동작함
<CyberChart data='...' />

최종 결과

이제 Notion에서 글을 쓰고 Published 체크박스를 켜면 5분 내에 블로그에 자동 반영된다. 코드를 건드릴 필요가 전혀 없다.

기존 .mdx 파일 기반 포스트도 그대로 유지되므로, 코드 블록이나 커스텀 컴포넌트가 필요한 고급 포스트는 파일시스템으로, 일반적인 글은 Notion으로 작성하는 하이브리드 운영이 가능하다.

EOF — 2026-04-03
> comments