Notion + Next.js 블로그 처음부터 — 카테고리 트리까지 설계
이 가이드가 안내하는 것
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 기대 효과: "블로그 글 쓰는데 카테고리 피커에 잘못된 옵션이 뜬다" · "계층을 나중에 붙이려니 기존 데이터가 꼬인다" 같은 문제를 사전에 막는다.
스택
| 레이어 | 선택 | 이유 | CMS | Notion (API v5, data sources) | 글 쓰면서 바로 발행, 계층 구조 내장 |
|---|---|---|---|---|---|
| 프레임워크 | Next.js App Router | ISR, RSC, MDX 서버 렌더 | 렌더링 | next-mdx-remote/rsc + rehype-pretty-code + KaTeX | MDX를 런타임에 파싱, 서버에서 하이라이트 |
| 배포 | Vercel (ISR 5분) | unstable_cache 태그 기반 revalidation | DB 부트스트랩 | Notion MCP | 수동 UI 클릭 없이 스키마·시드·relation 자동화 |
설계 원칙 — 세 가지 결정
DB를 만들기 전에 세 개 결정을 먼저 내린다. 나중에 바꾸면 비용이 크다.
결정 1: 컨텐츠 타입 몇 개?
블로그 글만 둘 건가, 프로젝트 포트폴리오 같은 별도 섹션도 둘 건가. 각 섹션은 보통 카테고리 체계도 독립이어야 한다 — 블로그의 "C++26"과 프로젝트의 "Web"은 같은 축이 아니다.
이 가이드는 Blog + Projects 두 섹션 가정.
결정 2: 스코프별로 카테고리 분리할지
함정: "하나의
CategoriesDB +scope멀티셀렉트(blog/projects)로 구분" 패턴은 겉보기엔 DRY해 보이는데 Notion UI와 상성이 나쁘다.
이유: Notion의 relation 피커는 DB 전체를 본다. 뷰에 필터를 걸어도 피커는 뷰 필터를 무시한다. 즉 Blog Posts의 Category 피커에 Projects 전용 카테고리까지 뜬다. 글 쓸 때마다 잘못된 옵션을 고를 여지가 생긴다.
권장: 타입별로 별도 Categories DB.
Blog Categories와Project Categories를 물리적으로 분리. 스코프가 DB 자체로 정해지므로 피커 오염이 구조적으로 불가능해진다.
결정 3: flat vs tree
카테고리에 계층이 필요한가? "C++26 > reflection > intrinsics" 같은 중첩.
- Flat:
Name,Slug,Order,Description,Published속성만. 단순. - Tree: 위 + 자기참조
Parentrelation.getCategoryTree()로 재귀 빌드. 나중에 flat → tree로 올리는 건 쉽지만, 트리에서 URL 구조가 달라지면 기존 링크가 깨진다. 처음부터 tree로 짜두고 실제 운영은 flat으로 하는 게 가장 안전하다.
아키텍처
Blog ↔ Projects는 서로 아무 relation도 공유하지 않는다. 섹션 간 독립성이 UI·URL·코드 전부에 일관되게 유지된다.
1단계: Notion Integration & MCP 서버 연결
Integration 발급
- Notion Integrations에서 Internal Integration 생성
ntn_으로 시작하는 API Key를 복사 →NOTION_API_KEY환경변수에 저장- 블로그용 최상위 페이지(예:
RICCILAB Blog)로 이동, 우측 상단⋯→ Connections → 방금 만든 Integration 추가
주의: Integration은 페이지 단위로 연결해야 그 아래 하위 DB까지 접근 가능. 워크스페이스 단위로 퍼지지 않는다.
(옵션) MCP 서버 연결
AI 어시스턴트(Claude 등)에 Notion MCP 서버를 붙이면 DB 생성, 시드, relation 설정을 채팅으로 지시할 수 있다. 이 가이드의 3~4단계는 MCP를 전제로 한다. 없다면 Notion UI에서 수동 클릭으로 같은 작업을 할 수 있지만 시간이 10배쯤 더 든다.
2단계: DB 네 개 스키마
Blog Posts / Projects 공통
| 속성 | 타입 | 설명 | Title | title | 글/프로젝트 제목 |
|---|---|---|---|---|---|
| Slug | text | URL 경로 (예: my-first-post) | Date | date | 작성일 / 업데이트일 |
| Description | text | 한 줄 요약 (OG/meta에 사용) | Category | relation | 해당 타입의 Categories DB를 가리킴 (단일 리프 권장) |
| Tags | multi_select | 카테고리와 다른 축의 검색 키워드 | Published | checkbox | 발행 여부 |
Projects에는 추가로:
Status(select: WIP / Active / Released / Archived)Tech(multi_select)Link,GitHub(url)Featured,Order(홈 노출용)
Blog Categories / Project Categories 공통
| 속성 | 타입 | 설명 |
|---|---|---|
| Slug | text | URL 조각 (cpp26, rust) |
| Order | number | 형제 간 정렬 |
| Published | checkbox | 공개 여부 |
왜
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-pages에 data_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.config의 remotePatterns
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분을 안 기다림.
마지막으로
설계의 핵심 세 가지:
- 카테고리 DB는 타입별로 분리.
scope필드로 억지로 묶지 말 것. - 트리 전제로 스키마를 짜고 (Parent self-relation), 실제 운영은 flat이어도 무방. 중간에 계층을 도입해도 DB 마이그레이션이 필요 없다.
- 카테고리 로직은 Next.js 쪽
categories.ts에 전부 모은다. 트리 빌드, 경로 해석, 체인 추적, 서브트리 수집이 공개 API로 노출되어 라우트, 브레드크럼, 필터 어디서든 재사용된다. Notion MCP가 붙어 있으면 1~4단계는 "이 스키마로 네 개 만들어 줘" 한 문장으로 끝난다. 사람은 설계 결정만 확정하고, 반복 노동은 AI에 맡긴다. 이제는 그런 시대가 왔다.