02.【个人网站】如何在Next中集成Notion数据库

在Next.js项目中集成Notion数据库可以使用Notion作为内容管理系统(CMS),并将其内容展示在网站上。以下是一个简单的步骤指南,帮助在Next.js中集成Notion数据库。
源码:https://github.com/Jessie-jzn/Jessie-Blog.dev
实现网站:https://jessie-blog.vercel.app/

基础准备

获取Notion API密钥和数据库ID

  1. 获取Notion API密钥:前往Notion开发者门户并创建一个新集成。创建后,会得到一个API密钥。
  2. 获取数据库ID:导航到想集成的Notion数据库,复制数据库页面的URL。数据库ID是URL中位于https://www.notion.so/?v=之间的一段字符串。

还不清楚的可以看上篇文章:【01.【个人网站】如何使用Notion作为数据库进行全栈开发】

使用官方SDK

步骤 1: 安装依赖

首先,需要安装Notion的官方SDK @notionhq/client 来与Notion API进行通信。可以通过npm或yarn安装它:

npm install @notionhq/client
# or
yarn add @notionhq/client

步骤 2: 设置Notion客户端

在Next.js项目的根目录下创建一个文件lib/notion.js,并配置Notion客户端:

// lib/notion.js
import { Client } from '@notionhq/client';

const notion = new Client({
  auth: process.env.NOTION_API_KEY,
});

export const getDatabase = async (databaseId) => {
  const response = await notion.databases.query({ database_id: databaseId });
  return response.results;
};

确保在项目的环境变量文件(.env.local)中添加Notion API密钥:

NOTION_API_KEY=your_secret_api_key

步骤 3: 获取数据库内容

在Next.js页面中,可以使用getStaticPropsgetServerSideProps来获取Notion数据库的内容。

// pages/index.js
import { getDatabase } from '../lib/notion';

export const getStaticProps = async () => {
  const databaseId = process.env.NOTION_DATABASE_ID;
  const posts = await getDatabase(databaseId);

  return {
    props: {
      posts,
    },
    revalidate: 1, // ISR (Incremental Static Regeneration)
  };
};

export default function Home({ posts }) {
  return (
    <div>
      <h1>My Notion Blog</h1>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>
            {post.properties.Name.title[0].plain_text}
          </li>
        ))}
      </ul>
    </div>
  );
}

确保在.env.local文件中也添加Notion数据库ID:

NOTION_DATABASE_ID=your_database_id

步骤 4: 部署与验证

最后,将Next.js项目部署到Vercel或其他平台,并验证是否能够正确获取并展示Notion数据库中的数据。

额外提示

  • 可以使用Notion页面的属性(如NameTagsDate等)来在页面上展示不同的数据。
  • 考虑使用getServerSideProps在每次请求时获取最新的数据,或者使用getStaticProps结合ISR来优化性能。

这样,就可以在Next.js项目中成功集成Notion数据库,并利用它来管理和展示内容。

使用封装请求URL的NotionAPI

我最后是使用了这个方法,为了方便后续使用react-notion-x 的封装组件

确保在.env.local文件中添加Notion数据库ID和Notion API密钥:

NOTION_DATABASE_ID=your_database_id
NOTION_API_KEY=your_secret_api_key

步骤1. 安装必要的依赖

首先,安装notion-typesnotion-utilsgot等依赖,以便处理Notion API的请求。

npm install notion-types notion-utils got p-map

步骤2. 创建封装Notion API的文件

在你的Next.js项目中创建一个文件,例如lib/NotionAPI.ts,用于封装与Notion API的交互。这个文件将包含与Notion页面和集合数据相关的API调用方法。

// lib/NotionAPI.ts
import * as notion from "notion-types";
import got, { OptionsOfJSONResponseBody } from "got";
import {
  getBlockCollectionId,
  getPageContentBlockIds,
  parsePageId,
  uuidToId,
} from "notion-utils";
import pMap from "p-map";

// 定义权限记录接口
export interface SignedUrlRequest {
  permissionRecord: PermissionRecord;
  url: string;
}

export interface PermissionRecord {
  table: string;
  id: notion.ID;
}

export interface SignedUrlResponse {
  signedUrls: string[];
}

// 定义NotionAPI类
export class NotionAPI {
  private readonly _apiBaseUrl: string;
  private readonly _authToken?: string;
  private readonly _activeUser?: string;
  private readonly _userTimeZone: string;

  constructor({
    apiBaseUrl = "<https://www.notion.so/api/v3>",
    authToken,
    activeUser,
    userTimeZone = "America/New_York",
  }: {
    apiBaseUrl?: string;
    authToken?: string;
    userLocale?: string;
    userTimeZone?: string;
    activeUser?: string;
  } = {}) {
    this._apiBaseUrl = apiBaseUrl;
    this._authToken = authToken;
    this._activeUser = activeUser;
    this._userTimeZone = userTimeZone;
  }

  // 获取页面内容
  public async getPage(
    pageId: string,
    {
      concurrency = 3,
      fetchMissingBlocks = true,
      fetchCollections = true,
      signFileUrls = true,
      chunkLimit = 100,
      chunkNumber = 0,
      gotOptions,
    }: {
      concurrency?: number;
      fetchMissingBlocks?: boolean;
      fetchCollections?: boolean;
      signFileUrls?: boolean;
      chunkLimit?: number;
      chunkNumber?: number;
      gotOptions?: OptionsOfJSONResponseBody;
    } = {}
  ): Promise<notion.ExtendedRecordMap> {
    const page = await this.getPageRaw(pageId, {
      chunkLimit,
      chunkNumber,
      gotOptions,
    });

    const recordMap = page?.recordMap as notion.ExtendedRecordMap;

    if (!recordMap?.block) {
      throw new Error(`Notion page not found "${uuidToId(pageId)}"`);
    }

    recordMap.collection = recordMap.collection ?? {};
    recordMap.collection_view = recordMap.collection_view ?? {};
    recordMap.notion_user = recordMap.notion_user ?? {};
    recordMap.collection_query = {};
    recordMap.signed_urls = {};

    if (fetchMissingBlocks) {
      while (true) {
        const pendingBlockIds = getPageContentBlockIds(recordMap).filter(
          (id) => !recordMap.block[id]
        );

        if (!pendingBlockIds.length) {
          break;
        }

        const newBlocks = await this.getBlocks(
          pendingBlockIds,
          gotOptions
        ).then((res) => res.recordMap.block);

        recordMap.block = { ...recordMap.block, ...newBlocks };
      }
    }

    const contentBlockIds = getPageContentBlockIds(recordMap);

    if (fetchCollections) {
      const allCollectionInstances: Array<{
        collectionId: string;
        collectionViewId: string;
      }> = contentBlockIds.flatMap((blockId) => {
        const block = recordMap.block[blockId].value;
        const collectionId =
          block &&
          (block.type === "collection_view" ||
            block.type === "collection_view_page") &&
          getBlockCollectionId(block, recordMap);

        if (collectionId) {
          return block.view_ids?.map((collectionViewId) => ({
            collectionId,
            collectionViewId,
          }));
        } else {
          return [];
        }
      });

      await pMap(
        allCollectionInstances,
        async (collectionInstance) => {
          const { collectionId, collectionViewId } = collectionInstance;
          const collectionView =
            recordMap.collection_view[collectionViewId]?.value;

          try {
            const collectionData = await this.getCollectionData(
              collectionId,
              collectionViewId,
              collectionView,
              {
                gotOptions,
              }
            );

            recordMap.block = {
              ...recordMap.block,
              ...collectionData.recordMap.block,
            };

            recordMap.collection = {
              ...recordMap.collection,
              ...collectionData.recordMap.collection,
            };

            recordMap.collection_view = {
              ...recordMap.collection_view,
              ...collectionData.recordMap.collection_view,
            };

            recordMap.notion_user = {
              ...recordMap.notion_user,
              ...collectionData.recordMap.notion_user,
            };

            recordMap.collection_query![collectionId] = {
              ...recordMap.collection_query![collectionId],
              [collectionViewId]: (collectionData.result as any)
                ?.reducerResults,
            };
          } catch (err: any) {
            console.warn(
              "NotionAPI collectionQuery error",
              pageId,
              err.message
            );
          }
        },
        {
          concurrency,
        }
      );
    }

    if (signFileUrls) {
      await this.addSignedUrls({ recordMap, contentBlockIds, gotOptions });
    }

    return recordMap;
  }

  public async addSignedUrls({
    recordMap,
    contentBlockIds,
    gotOptions = {},
  }: {
    recordMap: notion.ExtendedRecordMap;
    contentBlockIds?: string[];
    gotOptions?: OptionsOfJSONResponseBody;
  }) {
    recordMap.signed_urls = {};

    if (!contentBlockIds) {
      contentBlockIds = getPageContentBlockIds(recordMap);
    }

    const allFileInstances = contentBlockIds.flatMap((blockId) => {
      const block = recordMap.block[blockId]?.value;

      if (
        block &&
        (block.type === "pdf" ||
          block.type === "audio" ||
          (block.type === "image" && block.file_ids?.length) ||
          block.type === "video" ||
          block.type === "file" ||
          block.type === "page")
      ) {
        const source =
          block.type === "page"
            ? block.format?.page_cover
            : block.properties?.source?.[0]?.[0];

        if (source) {
          if (!source.includes("secure.notion-static.com")) {
            return [];
          }

          return {
            permissionRecord: {
              table: "block",
              id: block.id,
            },
            url: source,
          };
        }
      }

      return [];
    });

    if (allFileInstances.length > 0) {
      try {
        const { signedUrls } = await this.getSignedFileUrls(
          allFileInstances,
          gotOptions
        );

        if (signedUrls.length === allFileInstances.length) {
          for (let i = 0; i < allFileInstances.length; ++i) {
            const file = allFileInstances[i];
            const signedUrl = signedUrls[i];

            recordMap.signed_urls[file.permissionRecord.id] = signedUrl;
          }
        }
      } catch (err) {
        console.warn("NotionAPI getSignedfileUrls error", err);
      }
    }
  }

  public async getPageRaw(
    pageId: string,
    {
      gotOptions,
      chunkLimit = 100,
      chunkNumber = 0,
    }: {
      chunkLimit?: number;
      chunkNumber?: number;
      gotOptions?: OptionsOfJSONResponseBody;
    } = {}
  ): Promise<notion.PageChunk> {
    const parsedPageId = parsePageId(pageId);

    if (!parsedPageId) {
      throw new Error(`invalid notion pageId "${pageId}"`);
    }

    const body = {
      pageId: parsedPageId,
      limit: chunkLimit,
      chunkNumber: chunkNumber,
      cursor: { stack: [] },
      verticalColumns: false,
    };

    return this.fetch<notion.PageChunk>({
      endpoint: "loadPageChunk",
      body,
      gotOptions,
    });
  }

  public async getCollectionData(
    collectionId: string,
    collectionViewId: string,
    collectionView?: any,
    {
      limit = 9999,
      searchQuery = "",
      userTimeZone = this._userTimeZone,
      loadContentCover = true,
      gotOptions,
    }: {
      limit?: number;
      searchQuery?: string;
      userTimeZone?: string;
      loadContentCover?: boolean;
      gotOptions?: OptionsOfJSONResponseBody;
    } = {}
  ) {
    const type = collectionView?.type;

    const isBoardType = type === "board";
    const groupBy = isBoardType
      ? collectionView?.format?.board_columns_by
      : collectionView?.format?.collection_group_by;

    let filters = [];
    if (collectionView?.format?.property_filters) {
      filters = collectionView.format?.property_filters.map(
        (filterObj

: any) => ({
          property: filterObj?.property,
          filter: {
            operator: "and",
            filters: filterObj?.filter?.filters,
          },
        })
      );
    }

    const body = {
      collection: {
        id: collectionId,
      },
      collectionView: {
        id: collectionViewId,
      },
      loader: {
        type: "reducer",
        reducers: {
          collection_group_results: {
            type: "results",
            limit,
            loadContentCover,
          },
        },
        userTimeZone,
        limit,
        loadContentCover,
        searchQuery,
        userLocale: "en",
        ...(filters.length > 0 ? { filters } : {}),
        ...(groupBy
          ? {
              groupBy,
            }
          : {}),
      },
    };

    return this.fetch<notion.CollectionInstance>({
      endpoint: "queryCollection",
      body,
      gotOptions,
    });
  }

  private async fetch<R>({
    endpoint,
    body,
    gotOptions,
  }: {
    endpoint: string;
    body: unknown;
    gotOptions?: OptionsOfJSONResponseBody;
  }) {
    const url = `${this._apiBaseUrl}/${endpoint}`;
    const json = true;
    const method = "POST";

    const headers: Record<string, string> = {
      "Content-Type": "application/json",
    };

    if (this._authToken) {
      headers.cookie = `token_v2=${this._authToken}`;
    }

    if (this._activeUser) {
      headers["x-notion-active-user-header"] = this._activeUser;
    }

    try {
      const res = await got.post(url, {
        ...gotOptions,
        json,
        method,
        body,
        headers,
      });

      return res.body as R;
    } catch (err) {
      console.error(`NotionAPI error: ${err.message}`);
      throw err;
    }
  }

  private async getSignedFileUrls(
    urls: SignedUrlRequest[],
    gotOptions?: OptionsOfJSONResponseBody
  ): Promise<SignedUrlResponse> {
    return this.fetch<SignedUrlResponse>({
      endpoint: "getSignedFileUrls",
      body: { urls },
      gotOptions,
    });
  }

  private async getBlocks(
    blockIds: string[],
    gotOptions?: OptionsOfJSONResponseBody
  ): Promise<notion.PageChunk> {
    return this.fetch<notion.PageChunk>({
      endpoint: "syncRecordValues",
      body: {
        requests: blockIds.map((blockId) => ({
          id: blockId,
          table: "block",
          version: -1,
        })),
      },
      gotOptions,
    });
  }
}

步骤3. 在Next.js页面中使用封装的API

在Next.js页面中使用封装的NotionAPI类来获取Notion数据库的内容,并传递给react-notion-x组件进行渲染。

// pages/[pageId].tsx
import { GetServerSideProps } from 'next';
import { NotionAPI } from '../lib/NotionAPI';
import { NotionRenderer } from 'react-notion-x';
import 'react-notion-x/src/styles.css';

export const getServerSideProps: GetServerSideProps = async (context) => {
  const { pageId } = context.params;
  const notion = new NotionAPI();
  const recordMap = await notion.getPage(pageId as string);

  return {
    props: {
      recordMap,
    },
  };
};

const NotionPage = ({ recordMap }) => {
  return <NotionRenderer recordMap={recordMap} fullPage={true} darkMode={false} />;
};

export default NotionPage;

步骤4. 在Next.js中配置路由

确保[pageId].tsx文件可以通过[pageId]参数匹配路由,这样可以动态渲染不同的Notion页面。

总结

通过以上步骤,可以在Next.js项目中封装Notion API请求,并使用react-notion-x组件来渲染Notion页面。这样不仅简化了与Notion数据库的集成,还提高了代码的可维护性。

  • 8
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值