@hansolbangul/notion-render
v0.0.3
Published
Reusable Notion collection parsing, rendering, SEO, and Next.js helpers.
Maintainers
Readme
@hansolbangul/notion-render
@hansolbangul/notion-render는 공개 Notion 페이지를 CMS처럼 읽어서 Next.js 블로그나 문서 사이트에 연결할 때 쓰는 라이브러리입니다.
이 패키지는 아래 흐름을 재사용 가능하게 분리하는 데 초점을 둡니다.
- Notion 컬렉션 페이지 읽기
- 페이지 속성 정규화
- 게시글 필터링
- 태그/카테고리 집계
generateStaticParams생성react-notion-x기반 렌더링- Next.js SEO 메타데이터 생성
- SSG / ISR 연결
중요:
- 이 패키지는 공식 Notion REST API가 아니라
notion-client기반의 public pagerecordMap방식을 사용합니다. - 즉, 공개 가능한 Notion 페이지를 읽는 구조입니다.
이 패키지가 하는 일
- Notion 컬렉션 페이지에서 row 목록을 가져옵니다.
- 각 row의 속성을 읽어 일반적인 JS 객체로 바꿉니다.
slug,status,type,date같은 값을 기준으로 게시 가능한 글만 골라냅니다.- 태그, 카테고리 같은 다중 선택 속성을 집계합니다.
- 상세 페이지 렌더링용
recordMap을 가져옵니다. react-notion-x렌더러를 감싼 공용 컴포넌트를 제공합니다.- Next.js
Metadata와 JSON-LD 생성 헬퍼를 제공합니다.
이 패키지가 하지 않는 일
- 카드 UI, 리스트 UI, 헤더, 푸터를 만들지는 않습니다.
- 댓글, 공유 버튼, 테마 토글까지 포함하지는 않습니다.
- 프로젝트별 고유한
BlogPost타입을 강제로 고정하지는 않습니다.
설치
이미 Next.js + React 앱이 있다면 보통 아래 한 줄이면 됩니다.
yarn add @hansolbangul/notion-render직접 의존성을 함께 명시해서 설치하고 싶다면:
yarn add @hansolbangul/notion-render notion-client notion-types notion-utils react-notion-x prismjs katex
yarn add next react react-dom모듈 구성
@hansolbangul/notion-render/server/notion-api
서버에서 Notion 데이터를 읽을 때 사용합니다.
주요 export:
createNotionClientgetCollectionItemsgetPageRecordMapresolvePageProperties
@hansolbangul/notion-render/server/filters
정규화된 row 데이터를 실제 게시글 목록으로 다듬을 때 사용합니다.
주요 export:
filterPublishedItemscountMultiValueOptionstoStaticParamsfindAdjacentSlugs
@hansolbangul/notion-render/next/metadata
Next.js App Router 기준 SEO 메타데이터를 만들 때 사용합니다.
주요 export:
buildSiteMetadatabuildArticleMetadatabuildWebsiteJsonLd
@hansolbangul/notion-render/react/NotionContent
클라이언트 컴포넌트에서 recordMap을 렌더링할 때 사용합니다.
주의:
- Notion 렌더링 스타일 CSS는 라이브러리 내부에서 자동 주입하지 않습니다.
- 앱 루트 레이아웃에서 아래 CSS를 직접 import 해야 합니다.
import "react-notion-x/src/styles.css";
import "prismjs/themes/prism-tomorrow.css";
import "katex/dist/katex.min.css";주요 export:
NotionContentdefaultMapPageUrl
빠른 시작
1. 컬렉션 페이지 읽기
import { getCollectionItems } from "@hansolbangul/notion-render/server/notion-api";
type BlogPost = {
id: string;
title: string;
slug: string;
status: string[];
type: string[];
tags?: string[];
summary?: string;
thumbnail?: string;
date?: {
start_date?: string;
};
createdTime: string;
fullWidth: boolean;
};
export async function getPosts(pageId: string) {
return getCollectionItems<BlogPost>({ pageId });
}2. 게시 가능한 글만 필터링
import { filterPublishedItems } from "@hansolbangul/notion-render/server/filters";
const publishedPosts = filterPublishedItems(posts, {
acceptStatus: ["Public"],
acceptType: ["Post"],
});3. 정적 params 생성
import { toStaticParams } from "@hansolbangul/notion-render/server/filters";
export async function generateStaticParams() {
const posts = await getPosts(process.env.NOTION_PAGE_ID!);
return toStaticParams(posts);
}4. 상세 페이지용 recordMap 읽기
import { getPageRecordMap } from "@hansolbangul/notion-render/server/notion-api";
export async function getPostDetail(postId: string) {
return getPageRecordMap(postId);
}5. Notion 페이지 렌더링
"use client";
import { NotionContent } from "@hansolbangul/notion-render/react/NotionContent";
import type { ExtendedRecordMap } from "notion-types";
type Props = {
recordMap: ExtendedRecordMap;
title: string;
};
export function ArticleBody({ recordMap, title }: Props) {
return (
<NotionContent
recordMap={recordMap}
className="bg-white"
pageTitle={<h1>{title}</h1>}
/>
);
}SEO 설정 예시
사이트 메타데이터
import {
buildSiteMetadata,
buildWebsiteJsonLd,
} from "@hansolbangul/notion-render/next/metadata";
export const metadata = buildSiteMetadata({
title: "내 노션 블로그",
description: "노션을 CMS로 사용하는 블로그",
siteUrl: "https://example.com",
keywords: ["blog", "notion"],
locale: "ko_KR",
siteName: "내 노션 블로그",
image: {
url: "/og.png",
alt: "내 노션 블로그",
width: 1200,
height: 630,
},
});
const websiteJsonLd = buildWebsiteJsonLd({
title: "내 노션 블로그",
description: "노션을 CMS로 사용하는 블로그",
siteUrl: "https://example.com",
siteName: "내 노션 블로그",
image: {
url: "/og.png",
width: 1200,
height: 630,
},
});게시글 메타데이터
import { buildArticleMetadata } from "@hansolbangul/notion-render/next/metadata";
export async function generateMetadata() {
return buildArticleMetadata({
title: post.title,
description: post.summary || post.title,
siteUrl: "https://example.com",
path: `/post/${post.slug}`,
keywords: post.tags,
image: post.thumbnail
? {
url: post.thumbnail,
alt: post.title,
width: 1200,
height: 630,
}
: undefined,
publishedTime: post.date?.start_date,
authors: post.author?.map((author) => author.name).filter(Boolean),
});
}SSG / ISR 추천 패턴
권장 흐름은 아래와 같습니다.
- 서버에서 컬렉션 row 전체를 읽습니다.
getCollectionItems로 일반 객체 배열을 만듭니다.filterPublishedItems로 게시 가능한 글만 남깁니다.toStaticParams를generateStaticParams에 연결합니다.unstable_cache로 목록과 상세recordMap을 캐시합니다.revalidateTag,revalidatePath로 온디맨드 ISR을 연결합니다.
예시:
import { unstable_cache } from "next/cache";
import { filterPublishedItems } from "@hansolbangul/notion-render/server/filters";
import {
getCollectionItems,
getPageRecordMap,
} from "@hansolbangul/notion-render/server/notion-api";
const REVALIDATE_SECONDS = 3600;
const REVALIDATE_TAG = "notion-posts";
export const getCachedPosts = unstable_cache(
async () =>
filterPublishedItems(
await getCollectionItems({
pageId: process.env.NOTION_PAGE_ID!,
}),
{
acceptStatus: ["Public"],
acceptType: ["Post"],
},
),
[REVALIDATE_TAG],
{
revalidate: REVALIDATE_SECONDS,
tags: [REVALIDATE_TAG],
},
);
export const getCachedRecordMap = unstable_cache(
async (pageId: string) => getPageRecordMap(pageId),
["notion-record-map"],
{
revalidate: REVALIDATE_SECONDS,
tags: [REVALIDATE_TAG],
},
);타입은 고정인가?
아니요. 아래 예시의 BlogPost 타입은 고정 스키마가 아니라 "사용 예시"입니다.
type BlogPost = {
id: string;
title: string;
slug: string;
status: string[];
type: string[];
tags?: string[];
summary?: string;
thumbnail?: string;
date?: {
start_date?: string;
};
createdTime: string;
fullWidth: boolean;
};정확히는 이렇게 이해하시면 됩니다.
getCollectionItems<T>()는 제네릭 함수라서 사용자가 원하는 타입T로 받을 수 있습니다.- 런타임에서 실제로 만들어지는 key는 Notion 컬렉션의 속성 이름을 기준으로 동적으로 결정됩니다.
- 다만 공용 헬퍼인
filterPublishedItems는 기본적으로title,slug,status,type,date같은 key 이름을 기대합니다. - 만약 Notion 속성명이 다르면 옵션으로 key 이름을 바꿔서 사용할 수 있습니다.
예를 들어 속성명이 완전히 다를 수도 있습니다.
type ArticleRow = {
id: string;
제목: string;
urlKey: string;
공개상태: string[];
문서종류: string[];
발행일?: {
start_date?: string;
};
createdTime: string;
fullWidth: boolean;
};
const rows = await getCollectionItems<ArticleRow>({
pageId: process.env.NOTION_PAGE_ID!,
});
const publishedRows = filterPublishedItems(rows, {
titleKey: "제목",
slugKey: "urlKey",
statusKey: "공개상태",
typeKey: "문서종류",
dateKey: "발행일",
acceptStatus: ["공개"],
acceptType: ["아티클"],
});즉:
- 정적 타입은 사용자가 정의합니다.
- 실제 데이터 key는 Notion 속성 이름에 따라 결정됩니다.
- 블로그용 헬퍼는 기본 key 규약을 가지지만, 일부는 옵션으로 바꿀 수 있습니다.
추천 타입 형태
아래 형태는 블로그 프로젝트에서 가장 무난한 권장 예시입니다.
type Post = {
id: string;
title: string;
slug: string;
summary?: string;
thumbnail?: string;
status: string[];
type: string[];
tags?: string[];
category?: string[];
author?: {
id: string;
name?: string;
profilePhoto?: string | null;
}[];
date?: {
start_date?: string;
end_date?: string;
};
createdTime: string;
fullWidth: boolean;
};개발
라이브러리 폴더에서:
yarn install
yarn build
yarn test상위 추출 작업 레포에서:
yarn build:notion-render
yarn test:notion-render배포
yarn install
yarn test
yarn npm publish --access public배포 패키지 이름:
@hansolbangul/notion-render