RICCILAB
> blog/lab/notion-nextjs-blog-from-scratch

Notion + Next.js 블로그 처음부터 — 카테고리 트리까지 설계

_DEV

이 가이드가 안내하는 것

Next.js 블로그에 Notion CMS를 붙이는 기본 연동Next.js 블로그에 Notion CMS 연동하기에서 따로 정리했다. 이 글은 그 위에 얹는 정보 설계 가이드다. 처음부터 제대로 짜서 나중에 DB 마이그레이션할 일을 피하려는 사람이 대상이다.

다루는 것:

  • Notion DB 네 개(Blog Posts / Projects / Blog Categories / Project Categories) 설계
  • 스코프별로 분리된 카테고리 트리 — 처음부터 이렇게 짜야 하는 이유
  • 계층 카테고리(self-relation Parent) + 브레드크럼 자동 생성
  • /blog/categories/parent/child 같은 중첩 URL 라우트
  • Notion MCP로 DB 네 개를 코드처럼 찍어내는 bootstrap 기대 효과: "블로그 글 쓰는데 카테고리 피커에 잘못된 옵션이 뜬다" · "계층을 나중에 붙이려니 기존 데이터가 꼬인다" 같은 문제를 사전에 막는다.

스택

레이어선택이유CMSNotion (API v5, data sources)글 쓰면서 바로 발행, 계층 구조 내장
프레임워크Next.js App RouterISR, RSC, MDX 서버 렌더렌더링next-mdx-remote/rsc + rehype-pretty-code + KaTeXMDX를 런타임에 파싱, 서버에서 하이라이트
배포Vercel (ISR 5분)unstable_cache 태그 기반 revalidationDB 부트스트랩Notion MCP수동 UI 클릭 없이 스키마·시드·relation 자동화

설계 원칙 — 세 가지 결정

DB를 만들기 전에 세 개 결정을 먼저 내린다. 나중에 바꾸면 비용이 크다.

결정 1: 컨텐츠 타입 몇 개?

블로그 글만 둘 건가, 프로젝트 포트폴리오 같은 별도 섹션도 둘 건가. 각 섹션은 보통 카테고리 체계도 독립이어야 한다 — 블로그의 "C++26"과 프로젝트의 "Web"은 같은 축이 아니다.

이 가이드는 Blog + Projects 두 섹션 가정.

결정 2: 스코프별로 카테고리 분리할지

함정: "하나의 Categories DB + scope 멀티셀렉트(blog/projects)로 구분" 패턴은 겉보기엔 DRY해 보이는데 Notion UI와 상성이 나쁘다.

이유: Notion의 relation 피커는 DB 전체를 본다. 뷰에 필터를 걸어도 피커는 뷰 필터를 무시한다. 즉 Blog Posts의 Category 피커에 Projects 전용 카테고리까지 뜬다. 글 쓸 때마다 잘못된 옵션을 고를 여지가 생긴다.

권장: 타입별로 별도 Categories DB. Blog CategoriesProject Categories를 물리적으로 분리. 스코프가 DB 자체로 정해지므로 피커 오염이 구조적으로 불가능해진다.

결정 3: flat vs tree

카테고리에 계층이 필요한가? "C++26 > reflection > intrinsics" 같은 중첩.

  • Flat: Name, Slug, Order, Description, Published 속성만. 단순.
  • Tree: 위 + 자기참조 Parent relation. getCategoryTree()로 재귀 빌드. 나중에 flat → tree로 올리는 건 쉽지만, 트리에서 URL 구조가 달라지면 기존 링크가 깨진다. 처음부터 tree로 짜두고 실제 운영은 flat으로 하는 게 가장 안전하다.

아키텍처

> DIAGRAMMERMAID.RENDER

Blog ↔ Projects는 서로 아무 relation도 공유하지 않는다. 섹션 간 독립성이 UI·URL·코드 전부에 일관되게 유지된다.


1단계: Notion Integration & MCP 서버 연결

Integration 발급

  1. Notion Integrations에서 Internal Integration 생성
  2. ntn_으로 시작하는 API Key를 복사 → NOTION_API_KEY 환경변수에 저장
  3. 블로그용 최상위 페이지(예: RICCILAB Blog)로 이동, 우측 상단 Connections → 방금 만든 Integration 추가

주의: Integration은 페이지 단위로 연결해야 그 아래 하위 DB까지 접근 가능. 워크스페이스 단위로 퍼지지 않는다.

(옵션) MCP 서버 연결

AI 어시스턴트(Claude 등)에 Notion MCP 서버를 붙이면 DB 생성, 시드, relation 설정을 채팅으로 지시할 수 있다. 이 가이드의 3~4단계는 MCP를 전제로 한다. 없다면 Notion UI에서 수동 클릭으로 같은 작업을 할 수 있지만 시간이 10배쯤 더 든다.


2단계: DB 네 개 스키마

Blog Posts / Projects 공통

속성타입설명Titletitle글/프로젝트 제목
SlugtextURL 경로 (예: my-first-post)Datedate작성일 / 업데이트일
Descriptiontext한 줄 요약 (OG/meta에 사용)Categoryrelation해당 타입의 Categories DB를 가리킴 (단일 리프 권장)
Tagsmulti_select카테고리와 다른 축의 검색 키워드Publishedcheckbox발행 여부

Projects에는 추가로:

  • Status (select: WIP / Active / Released / Archived)
  • Tech (multi_select)
  • Link, GitHub (url)
  • Featured, Order (홈 노출용)

Blog Categories / Project Categories 공통

속성타입설명
SlugtextURL 조각 (cpp26, rust)
Ordernumber형제 간 정렬
Publishedcheckbox공개 여부

scope 필드가 없나? DB 자체가 스코프다. 같은 스키마 두 DB를 따로 두면 코드에서 동일한 매핑 함수로 양쪽을 처리할 수 있고, Notion 피커 오염은 구조적으로 불가능해진다.


3단계: MCP로 DB 생성

4개 DB를 한 번에 찍는다. Parent self-relation은 DB가 존재해야 걸 수 있으므로 2-pass로 진행.

3-1. Pass 1 — Categories DB 두 개 (Parent 제외)

{
  "parent": { "page_id": "YOUR_ROOT_PAGE_ID" },
  "title": "Blog Categories",
  "properties": {
    "Name":        { "type": "title" },
    "Slug":        { "type": "text" },
    "Order":       { "type": "number" },
    "Description": { "type": "text" },
    "Published":   { "type": "checkbox" }
  }
}

Project Categories도 같은 스키마로 한 번 더. 반환된 data_source_id 두 개를 기록해 둔다.

3-2. Pass 2 — Parent self-relation 추가

notion-update-data-source로 방금 만든 두 DS 각각에 추가.

{
  "data_source_id": "BLOG_CATEGORIES_DS_ID",
  "schema_updates": {
    "Parent": {
      "type": "relation",
      "dataSourceUrl": "collection://BLOG_CATEGORIES_DS_ID"
    }
  }
}

3-3. Content DB 생성

Blog Posts / Projects를 만든다. Category relation은 처음부터 올바른 Categories DS를 가리키게.

{
  "parent": { "page_id": "YOUR_ROOT_PAGE_ID" },
  "title": "Blog Posts",
  "properties": {
    "Title":       { "type": "title" },
    "Slug":        { "type": "text" },
    "Date":        { "type": "date" },
    "Description": { "type": "text" },
    "Tags":        { "type": "multi_select" },
    "Category": {
      "type": "relation",
      "dataSourceUrl": "collection://BLOG_CATEGORIES_DS_ID"
    },
    "Published":   { "type": "checkbox" }
  }
}

Projects도 비슷하되 Project Categories DS를 가리키고 Status·Tech·Link·GitHub·Featured·Order를 추가.

Tip: 스크립트를 짤 필요 없다. AI에게 "위 스키마로 DB 네 개 만들고 ID 네 개 돌려줘"라고 시키면 MCP 호출 시퀀스를 알아서 낸다.


4단계: 카테고리 시드

notion-create-pagesdata_source_id를 타겟으로 주고 bulk 삽입.

Flat 예시 (Blog)

{
  "parent": { "data_source_id": "BLOG_CATEGORIES_DS_ID" },
  "pages": [
    { "properties": { "Name": "C++26",      "Slug": "cpp26",      "Order": 10, "Published": "__YES__" } },
    { "properties": { "Name": "Rust",       "Slug": "rust",       "Order": 20, "Published": "__YES__" } },
    { "properties": { "Name": "Playground", "Slug": "playground", "Order": 30, "Published": "__YES__" } },
    { "properties": { "Name": "Lab",        "Slug": "lab",        "Order": 40, "Published": "__YES__" } }
  ]
}

Notion의 checkbox SQLite 값은 "__YES__" / "__NO__" 문자열이다. true/false 불리언 아님.

Nested 예시

계층을 쓰려면 부모를 먼저 만들고, 자식의 Parent에 부모 페이지 URL을 연결.

{
  "parent": { "data_source_id": "BLOG_CATEGORIES_DS_ID" },
  "pages": [
    {
      "properties": {
        "Name": "Reflection",
        "Slug": "reflection",
        "Order": 10,
        "Parent": ["https://www.notion.so/CPP26_CATEGORY_ID"],
        "Published": "__YES__"
      }
    }
  ]
}

Parent는 relation이라 페이지 URL을 JSON 배열로 넘긴다.


5단계: src/lib/categories.ts

이제 Next.js 쪽. 두 DS를 병렬로 쿼리하고 트리를 빌드한다. 파일 전체.

import { Client } from "@notionhq/client";
import { unstable_cache } from "next/cache";
 
const notion = new Client({ auth: process.env.NOTION_API_KEY });
 
const BLOG_CATEGORIES_DATA_SOURCE_ID =
  process.env.NOTION_BLOG_CATEGORIES_DATABASE_ID ?? "";
const PROJECT_CATEGORIES_DATA_SOURCE_ID =
  process.env.NOTION_PROJECT_CATEGORIES_DATABASE_ID ?? "";
 
export type CategoryScope = "blog" | "projects";
 
export interface Category {
  id: string;
  name: string;
  slug: string;
  parentId: string | null;
  order: number;
  description: string;
  scope: CategoryScope[];
  published: boolean;
}
 
export interface CategoryNode extends Category {
  children: CategoryNode[];
  /** 루트 → 이 노드까지의 slug 체인 (inclusive) */
  path: string[];
  /** 루트 → 이 노드까지의 id 체인 (inclusive) */
  pathIds: string[];
}
 
function normalizeSlug(raw: string): string {
  return raw.trim().toLowerCase();
}
 
async function queryOneDataSource(
  dataSourceId: string,
  scope: CategoryScope
): Promise<Category[]> {
  if (!dataSourceId) return [];
  const response = await notion.dataSources.query({
    data_source_id: dataSourceId,
    filter: { property: "Published", checkbox: { equals: true } },
    sorts: [{ property: "Order", direction: "ascending" }],
  });
 
  return response.results.map((page: any) => {
    const props = page.properties;
    const name = props.Name?.title?.[0]?.plain_text ?? "Untitled";
    const slugRaw = props.Slug?.rich_text?.[0]?.plain_text ?? "";
    const parentRelation = props.Parent?.relation ?? [];
 
    return {
      id: page.id,
      name,
      slug: normalizeSlug(slugRaw || name),
      parentId: parentRelation[0]?.id ?? null,
      order: props.Order?.number ?? 999,
      description: props.Description?.rich_text?.[0]?.plain_text ?? "",
      scope: [scope], // 코드 레벨에서 스코프 태깅
      published: props.Published?.checkbox ?? false,
    } satisfies Category;
  });
}
 
export const getAllCategories = unstable_cache(
  async (): Promise<Category[]> => {
    const [blog, projects] = await Promise.all([
      queryOneDataSource(BLOG_CATEGORIES_DATA_SOURCE_ID, "blog"),
      queryOneDataSource(PROJECT_CATEGORIES_DATA_SOURCE_ID, "projects"),
    ]);
    return [...blog, ...projects];
  },
  ["notion-categories"],
  { tags: ["notion-categories"], revalidate: 300 }
);
 
function buildTree(categories: Category[], scope?: CategoryScope): CategoryNode[] {
  const eligible = scope
    ? categories.filter((c) => c.scope.includes(scope))
    : categories;
  const byId = new Map<string, CategoryNode>();
  for (const c of eligible) {
    byId.set(c.id, { ...c, children: [], path: [], pathIds: [] });
  }
  const roots: CategoryNode[] = [];
  for (const node of byId.values()) {
    const parent = node.parentId ? byId.get(node.parentId) : null;
    if (parent) parent.children.push(node);
    else roots.push(node);
  }
  const sortFn = (a: CategoryNode, b: CategoryNode) =>
    a.order - b.order || a.name.localeCompare(b.name);
  const fixPaths = (node: CategoryNode, parent: CategoryNode | null) => {
    node.path = parent ? [...parent.path, node.slug] : [node.slug];
    node.pathIds = parent ? [...parent.pathIds, node.id] : [node.id];
    node.children.sort(sortFn);
    for (const child of node.children) fixPaths(child, node);
  };
  roots.sort(sortFn);
  for (const r of roots) fixPaths(r, null);
  return roots;
}
 
export async function getCategoryTree(scope: CategoryScope): Promise<CategoryNode[]> {
  return buildTree(await getAllCategories(), scope);
}
 
/** URL chain (예: ["cpp26", "reflection"]) 을 트리 노드로 해석 */
export async function resolveCategoryPath(
  chain: string[],
  scope: CategoryScope
): Promise<CategoryNode | null> {
  if (chain.length === 0) return null;
  const lowered = chain.map(normalizeSlug);
  let level = await getCategoryTree(scope);
  let current: CategoryNode | null = null;
  for (const seg of lowered) {
    const match = level.find((n) => n.slug === seg);
    if (!match) return null;
    current = match;
    level = match.children;
  }
  return current;
}
 
/** 리프 id로 부모 체인을 거슬러 올라가 루트→리프 순으로 반환 */
export async function getCategoryChain(leafId: string | null): Promise<Category[]> {
  if (!leafId) return [];
  const all = await getAllCategories();
  const byId = new Map(all.map((c) => [c.id, c]));
  const chain: Category[] = [];
  const seen = new Set<string>();
  let cur = byId.get(leafId);
  while (cur && !seen.has(cur.id)) {
    seen.add(cur.id);
    chain.unshift(cur);
    cur = cur.parentId ? byId.get(cur.parentId) : undefined;
  }
  return chain;
}
 
/** 서브트리 ids 전체 (서브카테고리 포함 필터용) */
export function collectSubtreeIds(node: CategoryNode): string[] {
  const ids: string[] = [node.id];
  for (const child of node.children) ids.push(...collectSubtreeIds(child));
  return ids;
}

설계 포인트 세 가지

  • unstable_cache + revalidate: 300으로 Notion 호출을 5분 캐시. tags로 revalidation endpoint에서 수동 무효화 가능.
  • scope쿼리 시점이 아니라 코드가 태깅. DB에 scope 필드가 없으므로.
  • CategoryNode.path / pathIds를 빌드 타임에 계산. URL 생성·브레드크럼·서브트리 필터 전부 이 필드로 처리.

6단계: Blog Posts에서 Category 읽기

src/lib/notion.ts의 블로그 포스트 매핑에서 Category relation을 추출한다. 관계는 페이지 ID의 배열 — 첫 번째 리프만 쓰는 정책:

const categoryRelation = props.Category?.relation ?? [];
const categoryId: string | null = categoryRelation[0]?.id ?? null;
 
// 이후 categories.ts의 getCategoryChain(categoryId)로 루트→리프 해석

렌더 시점에 getCategoryChain(categoryId)로 체인을 받아 브레드크럼과 표시 이름을 채운다.


7단계: URL 라우트 설계

/blog/[slug] — 포스트 상세

표준 동적 라우트. generateStaticParams로 발행된 슬러그를 미리 빌드.

/blog/categories/[...slug] — 카테고리 필터

catch-all 세그먼트. ["cpp26"]["cpp26", "reflection"]도 같은 라우트가 받는다:

// app/blog/categories/[...slug]/page.tsx
export default async function Page({
  params,
}: { params: Promise<{ slug: string[] }> }) {
  const { slug } = await params;
  const node = await resolveCategoryPath(slug, "blog");
  if (!node) notFound();
 
  const ids = new Set(collectSubtreeIds(node));
  const allPosts = await getAllPosts();
  const posts = allPosts.filter(
    (p) => p.categoryId && ids.has(p.categoryId)
  );
 
  // ... 렌더
}

collectSubtreeIds 덕에 부모 카테고리를 열면 자식 카테고리의 글까지 자동으로 포함된다.

브레드크럼

포스트 상세에서.

const chain = await getCategoryChain(post.categoryId);
 
return (
  <nav>
    <Link href="/blog">blog</Link>
    {chain.map((c, i) => {
      const href = `/blog/categories/${chain
        .slice(0, i + 1)
        .map((x) => x.slug)
        .join("/")}`;
      return <Link key={c.id} href={href}>{c.slug}</Link>;
    })}
    <span>{post.slug}</span>
  </nav>
);

URL은 자동으로 자기 자신을 가리키는 누적형으로 생성된다. 카테고리가 나중에 중첩돼도 이 코드는 그대로 동작한다.


8단계: 배포 & 검증

Vercel 환경변수

NOTION_API_KEY=ntn_...
NOTION_BLOG_DATABASE_ID=<blog_posts_ds_id>
NOTION_PROJECTS_DATABASE_ID=<projects_ds_id>
NOTION_BLOG_CATEGORIES_DATABASE_ID=<blog_categories_ds_id>
NOTION_PROJECT_CATEGORIES_DATABASE_ID=<project_categories_ds_id>
REVALIDATION_SECRET=<any_secret>

Production, Preview, Development 모두 체크. 설정 후 반드시 Redeploy — push 없이는 기존 빌드에 env가 안 먹는다.

next.configremotePatterns

Notion 이미지는 S3에서 서명된 URL로 나온다:

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

검증 체크리스트

  • /blog 랜딩의 ENTRIES 카운트가 기대값
  • /blog/categories/cpp26(또는 시드한 slug)가 404 안 남
  • 포스트 상세의 브레드크럼이 루트→리프 순서로 표시
  • /blog/categories/cpp26/reflection 같은 중첩 slug도 해석됨 (nested 시드했을 때)
  • Notion에서 Published 토글 → 5분 내(또는 revalidate API 호출 시 즉시) 블로그 반영

확장 포인트

가장 자주 붙이는 후속 작업들:

  • Featured 체크박스 → 홈 카드 노출 로직
  • Status 같은 select → 보드 뷰 + 공개 /projects?status=wip 라우트
  • 다국어 slug: Slug_ko / Slug_en 두 필드. locale별 라우트 분리.
  • tag ≠ category: Tags(multi_select)는 검색 키워드 축, Category(relation)는 정보 구조 축. 둘을 섞지 않는 게 중요.
  • 카테고리 랜딩의 설명: Category.Description을 카테고리 페이지 상단에 렌더.
  • On-demand revalidation: Notion 버튼 → webhook → /api/revalidate로 즉시 반영. 5분을 안 기다림.

마지막으로

설계의 핵심 세 가지:

  1. 카테고리 DB는 타입별로 분리. scope 필드로 억지로 묶지 말 것.
  2. 트리 전제로 스키마를 짜고 (Parent self-relation), 실제 운영은 flat이어도 무방. 중간에 계층을 도입해도 DB 마이그레이션이 필요 없다.
  3. 카테고리 로직은 Next.js 쪽 categories.ts에 전부 모은다. 트리 빌드, 경로 해석, 체인 추적, 서브트리 수집이 공개 API로 노출되어 라우트, 브레드크럼, 필터 어디서든 재사용된다. Notion MCP가 붙어 있으면 1~4단계는 "이 스키마로 네 개 만들어 줘" 한 문장으로 끝난다. 사람은 설계 결정만 확정하고, 반복 노동은 AI에 맡긴다. 이제는 그런 시대가 왔다.
EOF — 2026-04-19
> comments