next 블로그에 notion api 연결하기
이 글은, 컴포넌트를 만들거나 next
의 기능을 담고 있지는 않습니다. notion api
를 연결해 사용하는 방법에이 주 내용입니다. next
는 12버전입니다.
notion api를 쓰게된 이유
저의 블로그는, 원래는 mdx
문서들을 기반으로 작동되고 있었습니다. 이 mdx
문서를 git
에 올려주면 블로그가 최신화됩니다. 이를 위해 content-layer
등의 오픈소스를 사용하기도 했었습니다. 그런데, mdx
문서를 통해 블로그를 하다보니 귀찮아서.. 라는 이유로 블로그의 포스팅을 거의 하지 않게 되었습니다. 단순히 글을 하나 작성하려고 해도, 글을 작성한 후 git
에 따로 올려야 했으며 이미지를 사용하는 문제도 힘들었습니다. 이를 해결하기 위해 notion api
를 통해 notion
으로부터 데이터를 받아오는 방향을 생각하게 되었습니다.
notion api 시작하기
우선 notion api
를 연결하고 프라이빗 API 토큰을 얻어야 합니다. 전체적인 내용은 여기 공식 페이지에서 확인하실 수 있습니다.
여기 페이지에서 연결을 만들고, 위처럼 프라이빗 API
토큰을 생성할 수 있습니다.
notion
페이지의 주소를 통해서 데이터베이스 id
를 얻을 수 있습니다.
이 두가지 프라이빗 토큰과 데이터베이스 ID를 모두 얻고 시작해야합니다!
notion 페이지 프로퍼티 정리하기
또한 아래에 진행하기 이전에 notion
페이지의 프로퍼티를 정리해두는 것이 좋습니다. 이 프로퍼티들은, 만들어지게 될 블로그에서 정보를 내려주는 데에 사용합니다. seo
에도 도움이 될 수있습니다. 저는 위와 같은 프로퍼티를 사용했습니다. 이 부분은, 개인적으로 필요하다고 생각하는 부분을 적용하시면 될 것 같습니다.
다만, slug
는 블로그의 slug
가 될 것이고 이를 통해 데이터를 받아오는 부분이 많아 필수적인 부분이라 생각합니다.
notion api 연결하기
yarn add @notionhq/client
notion
에서 제공하고 있는 패키지를 사용해 연결할 수 있습니다. url endpoint
를 이용하는 방법도 존재하지만, 개인적으로 가장 간단한 방법이라 생각했습니다.
const notion = new Client({ auth: process.env.NOTION_TOKEN, // 환경변수에 선언한 노션 프라이빗 토큰입니다. }); export const getAllPublished = async (database: string) => { // 추후에 다른 `databaseId` 를 사용할 일도 있을 것 같아, 매개변수로 주었습니다. const posts = await notion.databases.query({ database_id: database, filter: { property: "Published", checkbox: { equals: true, }, }, // filter option입니다. // 저는 `Published` 프로퍼티가 checked 되어있는 페이지만을 // 불러오기 위해 위와같이 진행했습니다. sorts: [ { property: "Date", direction: "descending", }, ], // `Date` 의 날짜를 기준으로 정렬했습니다. }); const allPosts = posts.results; return allPosts.map((post: any) => { return getMetaData(post); }); };
위와 같은 방법을 통해 notion databse
에 저장되어있는 글들을 불러올 수 있습니다.
그런데 위와 같이 데이터를 불러오면 굉장히 많은 데이터들이 한꺼번에 오는 것을 알 수 있습니다.
{ object: 'page', ... cover: { type: 'external', external: { url: 'https://res.cloudinary.com/ddzuhs646/image/upload/v1675131088/blog/b5c38b09-08b8-4bb7-ad04-9bdb98dea672/j0pszvniyrlijwnjvwbf.png' } }, icon: { type: 'emoji', emoji: '🕢' }, parent: { type: 'database_id', database_id: '7521464c-c7d6-46b5-acdc-83c1d8d2dc86' }, archived: false, properties: { '생성일': { id: '%3BHkX', type: 'created_time', created_time: '2023-01-03T03:59:00.000Z' }, Last_Date: { id: 'Hi%7Cz', type: 'date', date: [Object] }, Published: { id: 'M%5EH_', type: 'checkbox', checkbox: true }, '태그': { id: 'ba%5Bl', type: 'multi_select', multi_select: [Array] }, Date: { id: 'm%3Dko', type: 'date', date: [Object] }, Description: { id: 'nt%60u', type: 'rich_text', rich_text: [Array] }, slug: { id: 'oMJH', type: 'rich_text', rich_text: [Array] }, '이름': { id: 'title', type: 'title', title: [Array] } }, url: 'https://www.notion.so/b5c38b0908b84bb7ad049bdb98dea672' }
위와 같은 데이터 중에서 우리가 필요한 부분만을 추출해야 합니다. 저는 아래와 같은 방식으로 진행했습니다.
const getTags = (tags: { name: string }[]) => { return tags.map((tag) => tag.name); }; const getMetaData = (post: any) => { return { id: post.id, cover: post.cover?.external?.url ?? null, title: post.properties.이름.title[0].plain_text, tags: getTags(post.properties.태그.multi_select), description: post.properties.Description.rich_text[0].plain_text, date: dateToKorean(post.properties.Date.date.start), last_edit: dateToKorean(post.properties.Last_Date.date.start), slug: post.properties.slug.rich_text[0].plain_text, // 이 부분은 노션 api의 프로퍼티를 보고 불러온 부분입니다! // 이러한 느낌을 통해, 필요한 프로퍼티만을 불러오시면 됩니다. }; }; function dateToKorean(dateString: string) { const parsedDate = new Date(dateString).toLocaleDateString("ko-kr", { year: "numeric", month: "long", day: "numeric", }); return parsedDate; } // 날짜만 간단히 한국식으로 파싱하는 함수입니다.
위와 같은 방법을 통해 이제 우리는 필요한 정보만을 얻을 수 있습니다. 이러한 정보를 통해 Card
컴포넌트 등을 구현할 수 있습니다. 커버 이미지와, 제목, 슬러그 등의 데이터를 가지고 올 수 있기 때문입니다.
데이터베이스에서 데이터 불러오기
// pages/post/index.tsx export default function Post({ posts, filterOptions, }: InferGetStaticPropsType<typeof getStaticProps>) { // posts 정보들을 통해 카드들을 렌더링합니다. return ( <> <FilterOptions /> <Cards posts={posts} /> </> ); } export const getStaticProps = async () => { const DATA_BASE_ID = process.env.DATABASE_ID as string; const posts = await getAllPublished(DATA_BASE_ID); // 위에서 만든 함수를 통해 데이터를 불러옵시다. return { props: { posts }, }; };
저는 위와 같은 카드 컴포넌트를 만들어, 렌더링을 진행했습니다.
페이지 불러오기
이제, 모든 페이지들의 데이터를 불러올 수 있으므로 이를 이용해 개별 페이지의 데이터를 불러와야 합니다. 저는 아래와 같은 함수를 따로 작성했습니다.
export const getPage = async (slug: string) => { // 저는 `slug` 를 매개변수로 사용했습니다. const response = await notion.databases.query({ database_id: process.env.DATABASE_ID, filter: { property: "slug", formula: { string: { equals: slug, }, // 프로퍼티 slug가 같은 페이지를 불러옵니다. }, }, }); return response.results[0]; };
그런데 위와 같이 페이지들만을 불러온다고 해도 바로 렌더링할 수 없습니다. json
형태의 객체로 표현되어 데이터가 전달되어 오기 때문입니다. 그리고 사실 원래는 여기서 얻어온 pageId
를 사용해 다시한번 blocks
들만을 불러와야 합니다. blocks
들을 불러오면 노션 페이지의 blocks
들이 객체형태로 표현되어 출력됩니다.
export const getAllBlocksBySlug = async (slug: string) => { try { const response = await getPage(slug); const id = response.id; const blocks = await notion.blocks.children.list({ block_id: id, }); return blocks.results; } catch { return null; } }; // 위와 같은 함수로 `blocks`들을 모아운 데이터를 확인할 수 있습니다. // 하지만, 이를 `markdown`으로 변환해주는 라이브러리를 사용할 수 있습니다.
Markdown 으로 변환하기
그렇지만, 이를 위해 사용할 수 있는 라이브러리가 존재합니다.
yarn add notion-to-md
const { NotionToMarkdown } = require("notion-to-md"); const n2m = new NotionToMarkdown({ notionClient: notion }); const notion = new Client({ auth: process.env.NOTION_TOKEN, }); export const getPost = async (slug: string) => { const page = await getPage(slug); const metadata = getMetaData(page); // 페이지에도 metadata를 사용할 예정이라 불러왔습니다. // 위에서 불러온 page.id를 사용해 라이브러리를 이용합니다. const mdBlocks = await n2m.pageToMarkdown(page.id); const markdown = n2m.toMarkdownString(mdBlocks); // markdown string형태로 페이지 데이터를 변환해줍니다. const headings = await n2m.pageToMarkdown(page.id, 2); // headings는 toc를 만들때 주로 사용해 저는 `headings`를 불러왔습니다. return { metadata, markdown, headings, }; };
그런데 위에 불러온 markdown
은 단순 string
입니다. 이 markdown
을 rendering
해 주어야 합니다. 혹시 table of contents
를 만들 예정이시라면 remark-slug
를 같이 사용하시는 것도 좋습니다.
yarn add react-markdown remark-slug
import ReactMarkdown from "react-markdown"; import slug from "remark-slug"; const MarkDown = ({ markdownString }: { markdownString: string }) => { return ( <ReactMarkdown remarkPlugins={[slug]} // `heading`들의 id를 `slug`형태로 만들어줍니다. > {markdownString} </ReactMarkdown> // 위에서 받아온 markdown string을 컴포넌트로 렌더링해줍니다. ); };
위와 같은 형태로 렌더링된 마크다운은 다음처럼 표시됩니다.
그리고 remark-slug
를 이용해 아래와 같이 heading
에 id
를 달아주어 toc
하이퍼링크로도 이용할 수 있습니다.
개별 페이지에 적용하기
// pages/post/[slug].tsx const Post = ({ post }: { post: PostType }) => { const { markdown, headings, metadata } = post; return ( <section className='mt-10'> <PostHeader {...metadata} /> // metadata를 이용해 header부분을 만들어 주었습니다. <div className='mt-5 lg:flex lg:flex-row-reverse'> <MarkDown markdownString={markdown} /> </div> </section> ); }; export const getStaticProps = async (context: { params: Params }) => { const { slug } = context.params; const post = await getPost(slug as string); return { props: { post }, revalidate: 60, }; }; export const getStaticPaths = async () => { const DATA_BASE_ID = process.env.DATABASE_ID as string; const posts = await getAllPublished(DATA_BASE_ID); const paths = posts.map(({ slug }: MetaData) => ({ params: { slug }, })); // paths들을 만들어줍니다. return { paths, fallback: 'blocking', }; };
위와 같이 getStaticProps
과 getStaticPaths
을 사용해 사전렌더링 된 페이지를 확인할 수 있습니다. revlidate
는 제가 직접 따로 퍼블리시를 하지 않아도, notion
에 바뀐 내용이 있다면 다시 렌더링하도록 추가했습니다.
그런데, 위와 같이 진행하고 나면 image
에 커다란 문제가 발생했음을 알 수 있습니다. 블로그에 들어가보면 언제는 이미지가 잘 나오고, 언제는 이미지가 깨져나옵니다..! 이 문제의 해결기를 여기서 진행했습니다.