개요
최근에 노션 데이터베이스에 저장된 데이터를 기반으로 Next.js 프로젝트를 만들었다. 이 때의 경험을 토대로, 노션과 Next.js에 대해 어느 정도의 경험이 있다고 가정하고 노션 데이터베이스 기반 Next.js 프로젝트를 구현하는 방법을 정리해보려고 한다.
구현 순서
전체 과정을 요약하면 아래와 같다.
- 노션 페이지 생성
- 데이터베이스 생성
- API key 발급
- Next.js 프로젝트 생성
- 데이터베이스 연결
- 페이지 렌더링
- 배포
각 단계별로 살펴보도록 하자.
구현
1. 노션 페이지 생성
노션에 접속해서 데이터베이스로 사용할 새로운 페이지를 생성한다.
2. 데이터베이스 생성
생성된 페이지에서 데이터베이스를 생성한다.
3. API Key 발급
-
https://www.notion.com/my-integrations 에서 새 API 통합 만들기 버튼을 클릭한다.
-
기본 정보를 입력해서 API를 생성한다.
-
secret key를 확인하고, 복사해서 저장한다.
4. Next.js 프로젝트 생성
npx create-next-app@latest
로Next.js
프로젝트를 생성한다.- VsCode로 생성한 프로젝트를 열고, root 폴더에
.env
파일을 생성한다. .env
파일에 발급받은 API key와 Database 주소를 추가한다..gitignore
에.env
를 추가한다.- 완료되었으면,
npm run dev
로 정상적으로 프로젝트가 실행되는지 확인해본다. - 문제없이 실행된다면, 이제 노션 데이터베이스의 데이터를 가져와서 보여주기만 하면 된다.
5. 노션 데이터베이스 연결
-
패키지 설치
- 노션 데이터베이스의 데이터를 가져오기 위해, 라이브러리를 설치하도록 하자.
- @notionhq/client: Notion API 사용을 위해 설치한다.
- @notion-render/client: 가져온 데이터 렌더링을 위해 설치한다.
설치 명령어 npm install @notionhq/client @notion-render/client
-
함수 구현
utils
폴더를 생성하고, 그 안에notion.ts
파일을 생성해서, 노션 데이터베이스의 데이터를 가져오기 위한 함수를 구현한다. filter나 sort 등의 옵션은 상황에 따라 바뀔 수 있다.
utils/notion.ts import 'server-only'; import { Client } from '@notionhq/client'; import { BlockObjectResponse, DatabaseObjectResponse, PageObjectResponse, } from '@notionhq/client/build/src/api-endpoints'; import { NotionRenderer } from '@notion-render/client'; import hljsPlugin from '@notion-render/hljs-plugin'; import { TNotionPage } from '@/app/types'; export const notionClient = new Client({ auth: process.env.NOTION_TOKEN, }); const databaseId = process.env.NOTION_DATABASE_ID!; const queryNotionDatabase = async ( pageSize?: number, level?: string ): Promise<TNotionPage[]> => { try { const response = await notionClient.databases.query({ database_id: databaseId, page_size: pageSize, filter: { and: [ { property: 'Status', status: { equals: 'Published', }, }, { property: 'Level', select: { equals: level ?? '', }, }, ], }, sorts: [ { property: 'PublishedDate', direction: 'descending', }, ], }); return response.results as (DatabaseObjectResponse & TNotionPage)[]; } catch (error) { console.error('Error occurred while fetching pages:', error); throw new Error('Failed to fetch pages. Please try again later.'); } }; export const getLatestNotionPages = async ( maxLatestPosts: number ): Promise<TNotionPage[]> => { try { return await queryNotionDatabase(maxLatestPosts); } catch (error) { throw new Error('Failed to fetch latest posts. Please try again later.'); } }; export const getAllNotionPages = async (): Promise<TNotionPage[]> => { try { return await queryNotionDatabase(); } catch (error) { throw new Error('Failed to fetch all posts. Please try again later.'); } }; export const getNotionPagesByLevel = async ( level: string ): Promise<TNotionPage[]> => { try { return await queryNotionDatabase(0, level); } catch (error) { throw new Error( `Failed to fetch ${level} posts. Please try again later.` ); } }; export const getNotionPageDataBySlug = async ( slug: string ): Promise<PageObjectResponse & TNotionPage> => { try { const response = await notionClient.databases.query({ database_id: databaseId, filter: { property: 'Slug', rich_text: { equals: slug, }, }, }); return response.results[0] as PageObjectResponse & TNotionPage; } catch (error) { console.error('Error occurred while fetching page:', error); throw new Error('Failed to fetch page. Please try again later.'); } }; export const getPageContent = async ( pageId: string ): Promise<BlockObjectResponse[]> => { try { const response = await notionClient.blocks.children.list({ block_id: pageId, }); return response.results as BlockObjectResponse[]; } catch (error) { console.error('Error occurred while fetching page contents:', error); throw new Error( 'Failed to fetch page contents. Please try again later.' ); } }; export const renderPageContent = async ( content: BlockObjectResponse[] ): Promise<string> => { const notionRenderer = new NotionRenderer({ client: notionClient, }); notionRenderer.use(hljsPlugin({})); try { return await notionRenderer.render(...content); } catch (error) { throw new Error('Failed to render page. Please try again later'); } };
6. 페이지 렌더링
-
첫 페이지 - Hero와 최신 게시글 5개를 표시한다.
app/page.tsx import { Suspense } from 'react'; import { getLatestNotionPages } from '@/app/utils/notion'; import { Hero, Loading, PostList } from '@/app/components'; import { LATEST_POSTS } from '@/app/lib/constants'; import { TNotionPage } from '@/app/types'; export default async function Home() { let latestPosts: TNotionPage[] | undefined = []; latestPosts = await getLatestNotionPages(LATEST_POSTS); return ( <> <Hero /> <Suspense fallback={<Loading arrayLength={LATEST_POSTS} />}> {latestPosts ? <PostList posts={latestPosts} /> : <>No Posts</>} </Suspense> </> ); }
-
전체 게시글 목록을 보여주기 위해, app 폴더 안에 posts 폴더를 생성한다. 그 안에 page.tsx 파일을 생성한다.
posts/page.tsx import { Suspense } from 'react'; import type { Metadata } from 'next'; import { getAllPosts, getPostsByLevel } from '@/app/utils/notion'; import { Loading, PostList, Levels } from '@/app/components'; import { TNotionPage } from '@/app/types'; export const metadata: Metadata = { title: 'Posts', }; type Props = { searchParams: { [key: string]: string }; }; export default async function PostsPage({ searchParams }: Props) { let allPosts: TNotionPage[] | undefined = []; if (!searchParams || searchParams.level === 'All') { allPosts = await getAllPosts(); } else { allPosts = await getPostsByLevel(searchParams.level); } return ( <> <Levels /> <Suspense fallback={<Loading arrayLength={allPosts?.length} />}> {allPosts ? <PostList posts={allPosts} /> : <>No Posts</>} </Suspense> </> ); }
-
posts 폴더 안에
[slug]
폴더를 생성하고, 그 안에 page.tsx 파일을 생성한다.slug
주소를 기반으로 상세 페이지를 생성하기 위함이다.[slug]/page.tsx import type { Metadata } from 'next'; import { notFound } from 'next/navigation'; import { getPageContent, getPageDataBySlug, renderPageContent, } from '@/app/utils/notion'; import { PostHeader, Post, Comments } from '@/app/components'; type Props = { params: { slug: string }; }; export async function generateMetadata({ params, }: Props): Promise<Metadata> { const post = await getPageDataBySlug(params.slug); const title = post?.properties.Title.title[0].plain_text; const description = post?.properties.Description.rich_text[0].plain_text; return { title: title, description: description, openGraph: { title: title, description: description, }, }; } export default async function PostPage({ params }: Props) { const post = await getPageDataBySlug(params.slug); try { if (!post) { throw new Error('Post not found'); } } catch (error) { notFound(); } const content = await getPageContent(post.id); const html = await renderPageContent(content!); return ( <article className="m-4 mx-auto flex w-full flex-col rounded-lg border-2 p-4"> <PostHeader title={post.properties.Title?.title[0]?.plain_text} description={post.properties.Description?.rich_text[0]?.plain_text} publishedDate={post.properties.PublishedDate?.date.start} problemLink={post.properties.ProblemLink?.url} level={post.properties.Level?.select.name} /> <Post content={html} /> <Comments /> </article> ); }
7. 배포
프로젝트가 정상적으로 동작하고, 빌드가 성공하는지 확인한다. 문제가 없다면, Vercel을 통해서 배포해보자.
-
Vercel에 접속해서, 프로젝트를
import
한다. -
Settings의 Environment Variables에 이전에 만들었던
.env
파일의 환경 변수를 등록한다. -
배포가 완료되면, 생성된 URL로 접속해서 정상적으로 보이는지 확인한다. 오류없이 동작한다면 모든 단계가 완료되었다.
이제 노션에서 글을 작성하면, 프로젝트를 다시 배포하지 않아도, 게시글이 자동으로 업데이트 될 것이다.