Tech| 읽는 데 8분

Next.js에서 노션 데이터베이스 사용하기

Next.js 프로젝트에서 노션 데이터베이스를 사용하는 방법의 단계별 정리

#Nextjs#Notion#TailwindCSS
목차

개요

최근에 노션 데이터베이스에 저장된 데이터를 기반으로 Next.js 프로젝트를 만들었다. 이 때의 경험을 토대로, 노션과 Next.js에 대해 어느 정도의 경험이 있다고 가정하고 노션 데이터베이스 기반 Next.js 프로젝트를 구현하는 방법을 정리해보려고 한다.

구현 순서

전체 과정을 요약하면 아래와 같다.

  1. 노션 페이지 생성
  2. 데이터베이스 생성
  3. API key 발급
  4. Next.js 프로젝트 생성
  5. 데이터베이스 연결
  6. 페이지 렌더링
  7. 배포

각 단계별로 살펴보도록 하자.

구현

1. 노션 페이지 생성

노션에 접속해서 데이터베이스로 사용할 새로운 페이지를 생성한다.

2. 데이터베이스 생성

생성된 페이지에서 데이터베이스를 생성한다.

3. API Key 발급

  1. https://www.notion.com/my-integrations 에서 새 API 통합 만들기 버튼을 클릭한다.

  2. 기본 정보를 입력해서 API를 생성한다.

  3. secret key를 확인하고, 복사해서 저장한다.

4. Next.js 프로젝트 생성

  1. npx create-next-app@latestNext.js 프로젝트를 생성한다.
  2. VsCode로 생성한 프로젝트를 열고, root 폴더에 .env 파일을 생성한다.
  3. .env 파일에 발급받은 API key와 Database 주소를 추가한다.
  4. .gitignore.env를 추가한다.
  5. 완료되었으면, npm run dev로 정상적으로 프로젝트가 실행되는지 확인해본다.
  6. 문제없이 실행된다면, 이제 노션 데이터베이스의 데이터를 가져와서 보여주기만 하면 된다.

5. 노션 데이터베이스 연결

  1. 패키지 설치

    • 노션 데이터베이스의 데이터를 가져오기 위해, 라이브러리를 설치하도록 하자.
    • @notionhq/client: Notion API 사용을 위해 설치한다.
    • @notion-render/client: 가져온 데이터 렌더링을 위해 설치한다.
    설치 명령어
      npm install @notionhq/client @notion-render/client
  2. 함수 구현

    • 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. 페이지 렌더링

  1. 첫 페이지 - 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>
        </>
      );
    }
  2. 전체 게시글 목록을 보여주기 위해, 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>
        </>
      );
    }
  3. 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을 통해서 배포해보자.

  1. Vercel에 접속해서, 프로젝트를 import한다.

  2. Settings의 Environment Variables에 이전에 만들었던 .env 파일의 환경 변수를 등록한다.

  3. 배포가 완료되면, 생성된 URL로 접속해서 정상적으로 보이는지 확인한다. 오류없이 동작한다면 모든 단계가 완료되었다.

이제 노션에서 글을 작성하면, 프로젝트를 다시 배포하지 않아도, 게시글이 자동으로 업데이트 될 것이다.

구현 결과