Notion APIとデータベースを使ってCMSを作りベトナム語の単語学習アプリを作成した

## 使用技術
- Next.js
- TypeScript
- Notion SDK JS
- TailiwindCSS

## この記事を書いたきっかけ
こんにちは。
ITエンジニア１年生のひろっきーと申します。
私にはベトナム人のパートナーがおり、ベトナム語の習得をしたいと感じたが私自身エンジニア転職を未経験から学習しており、大量の時間とリソースをそちらに向けており交際２年を超えた段階でも汎用的な単語を少し扱える程度の語学力にとどまっている。

ただ私自身６月からエンジニアとして働くことになり、より多くの時間をプログラミングなどに時間を割いてしまうので、プログラミング学習とベトナム語の学習を同時にできるサービスの開発をしてみようということで今回のプロダクトを開発し始めました。

## アプリの設計イメージ
### アプリで出来ること
NotionアプリのテーブルビューをそのままNotion　APIを用いてDBとしてデータを保存し、CMSとしてアプリ側で閲覧出来るようにする。
### ページの全体像
Notionの該当ページはこんな感じ。
![](https://storage.googleapis.com/zenn-user-upload/19af835509bf-20220608.png)
このページからテーブルのカラムを取得してページでデータを表示する。
### テーブルカラムの役割
| テーブル名 | カラムの型 | 役割 |
| ---- | ---- | ---- |
| Name_vi | タイトル | ベトナム語 |
| Name_ja | テキスト | 該当ベトナム語の日本語訳 |
| ジャンル | マルチセレクト | タグ付け、ページのルーティングにも使用 |
| created_at | 作成日時 | テーブルの情報を取得する条件に使用 |

## 環境構築

### Notionのintegrationの作成
インテグレーションページ

https://www.notion.so/my-integrations

1.新しいインテグレーションを作成するを押す
![](https://storage.googleapis.com/zenn-user-upload/2d443d72adc3-20220609.png)

2.アプリの名前やワークスペース、権限の設定を行う
![](https://storage.googleapis.com/zenn-user-upload/9df4254563e1-20220609.png)
私の場合は今回は読み取り専用で使いたいのでコンテンツ機能のチェックボックスは一番上だけチェックしておきます。
3.設定が出来れば下部の送信ボタンを押す
4.設定が出来ればシークレットトークンをコピーします
![](https://storage.googleapis.com/zenn-user-upload/ed05a98a3e12-20220609.png)
:::message alert
シークレットトークンは自分のワークスペースにアクセスするための認証トークンです。他人に知られないように気をつけましょう
:::
### Notionアプリ側の設定
1.Notionの画面右上の共有ボタンを押す
![](https://storage.googleapis.com/zenn-user-upload/8cd824e17ea0-20220609.png)
2.自分の作成したインテグレーションを選択する
![](https://storage.googleapis.com/zenn-user-upload/200f1b416d62-20220609.png)

Notion側の設定は以上です。
### NotionのSDKを使う

#### 公式のGithubリポジトリ

https://github.com/makenotion/notion-sdk-js

#### SDKのインストール

```shell
npm install @notionhq/client

//or

yarn add @notionhq/client
```

#### エディタの設定
API_URLの設定方法
```
https://www.notion.so/xxxxxxxxxxxx/a8aec43384f447ed84390e8e42c2e089?v=...
                                  |--------- Database ID --------|
```
```jsx:.env
NEXT_PUBLIC_API_KEY=secret_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
NEXT_PUBLIC_API_URL=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```
API_URLは上記のDatabase IDを貼りつけて下さい。
NEXT_PUBLIC_API_KEYはインテグレーションからコピーしたトークンを貼り付けて下さい。
```jsx:notion.ts
import { Client } from "@notionhq/client"

export const notion = new Client({ auth: process.env.NEXT_PUBLIC_API_KEY })
export const databaseId = process.env.NEXT_PUBLIC_API_URL
```
このあたりはFirebaseなど使ったことある方などは直感的にわかるのではないでしょうか？
## NotionのAPIを実際に叩く
@notionhq/client@^1.0.4のJS-SDKを使用してAPIを叩いていく
### メソッドの実行

```jsx
const data = await notion.databases.retrieve({
    database_id: databaseId||"",
  })
```

GETメソッドはgetというメソッド名ではなくretrieveという名前らしいです。

### 返り値

```jsx
{
  object: 'database',
  id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxxxxx',
  cover: null,
  icon: { type: 'emoji', emoji: '🔉' },
  created_time: '2022-05-23T17:04:00.000Z',
  created_by: { object: 'user', id: 'xxxxxxxxxxxxxxxxxxxxxxxxxxx' },
  last_edited_by: { object: 'user', id: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxx' },
  last_edited_time: '2022-05-28T17:26:00.000Z',
  title: [
    {
      type: 'text',
      text: [Object],
      annotations: [Object],
      plain_text: 'ベトナム語辞書',
      href: null
    }
  ],
  properties: {
    created_at: {
      id: 'xxxxx',
      name: 'created_at',
      type: 'created_time',
      created_time: {}
    },
    'ジャンル': {
      id: 'xxxxx',
      name: 'ジャンル',
      type: 'multi_select',
      multi_select: [Object]
    },
    Name_ja: { id: 'xxxx', name: 'Name_ja', type: 'rich_text', rich_text: {} },
    Name_vi: { id: 'title', name: 'Name_vi', type: 'title', title: {} }
  },
  parent: { type: 'workspace', workspace: true },
  url: 'https://www.notion.so/xxxxxxxxxxxxxxxxxxxxxxxxxx',
  archived: false
}
```
？？？
なんか思ってたのと違う、、、
どうやら、ページ全体を取得しているようだ。

自分がほしいデータはpropertiesの中身のデータなのでどうやらこのメソッドではないようだ。。

### 取得方法を調べる

これに関しては下記の記事を参考にした。

https://qiita.com/pro_matuzaki/items/43d492f6491410e68e5d

この方の記事を読むと、どうやらデータベースのカラムを取得するのは、GETではなくPOSTを使うらしいです。（このあたりは直感的にわかりにくい？？）

### 改めて書き直したメソッド
```jsx
//apiを叩くメソッド
const data = await notion.databases.query({
    database_id: databaseId || "",
    sorts: [
      {
        property: "created_at",
        direction: "ascending",
      },
    ],
  });
```
queryメソッドがsortのように取得条件を指定して並び順を指定しないと上手く取得出来ないようです。

### 再度返り値の型
```jsx
//返り値の型
{
 object: 'list',
  results: [
    {
      object: 'page',
      id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxx',
      created_time: '2022-05-23T17:05:00.000Z',
      last_edited_time: '2022-05-27T22:30:00.000Z',
      created_by: [Object],
      last_edited_by: [Object],
      cover: null,
      icon: null,
      parent: [Object],
      archived: false,
      properties: [Object],
      url: 'https://www.notion.so/Xin-ch-o-27bccb88e5114bb6ac7a19d597e2cb61'
    },
}
```
ん〜よくわからんｗｗ
が、こちらにもpropertiesがあるのでアクセスしてみる。
```jsx
//apiを叩くメソッド
const data = await notion.databases.query({
    database_id: databaseId || "",
    sorts: [
      {
        property: "created_at",
        direction: "ascending",
      },
    ],
  });
  const results = data.results;
  const properties = results.map((result) => {
    return result.properties;
  });
```
### 再再度返り値の型
```jsx
//返り値の型
  {
    created_at: {
      id: 'xxxxx',
      type: 'created_time',
      created_time: '2022-05-23T17:05:00.000Z'
    },
    'ジャンル': { id: 'xxxxx', type: 'multi_select', multi_select: [Array] },
    Name_ja: { id: 'xxxx', type: 'rich_text', rich_text: [Array] },
    Name_vi: { id: 'title', type: 'title', title: [Array] }
  },
```
よしよし大分ほしいデータに近づいて来た。
それぞれのpropertiesの中のそれぞれtypeと同じkeyの中の配列の中に自分が今回使用したい値が眠っている、、もう少しだ。
しかし当初のアーキテクチャを上手く達成するためにはかなりNotion　Apiは使い勝手がまだまだ悪いと思う。
が無理やりゴリゴリ実装してみようと思う。
#### Name_vi
```jsx
title: Array(1)
0:
annotations: {bold: false, italic: false, strikethrough: false, underline: false, code: false, …}
href: null
plain_text: "Xin chào"
text: {content: 'Xin chào', link: null}
type: "text"
```
Notionのテーブルはネストされたページを返し必ず一つはこのtitleカラムが必須になっており、ネストされたページのh1として表示される役割があります。基本的にplain_textを使うのが良いかと思われます。
#### Name_ja
```jsx
rich_text: Array(1)
0:
annotations: {bold: false, italic: false, strikethrough: false, underline: false, code: false, …}
href: null
plain_text: "こんにちは"
text: {content: 'こんにちは', link: null}
type: "text"
```
これは日本語訳のカラムです。これもName_viと同じくplain_textを使うのが良いかと思われます。
#### ジャンル
```jsx
multi_select: Array(1)
0:
color: "blue"
id: "xxxx"
name: "日常会話"
```
これはtagの型ですがmulti_selectという形で返って来ます。
これのidを使ってルーティングを動的に出し分けます。

:::message
Notion　APIのidは半角英数字以外に記号も入って来るのでurlとして使えない文字列が入るので注意が必要です
:::

### ちなみにpropertiesは型はなくTSエラーになる。。。
NotionのSDKを使うと、レスポンスの型が```QueryDatabaseResponse```という名前で登録されているが。なぜかネストが深くなるとpropertiesの型はなく、基本的にidとobjectしか補完が効かない。。
私みたいな使い方はまだ想定されていないということでしょうか？？
とりあえず、解決出来なかったのでanyに（トホホ、、、、）
![](https://storage.googleapis.com/zenn-user-upload/599ec5b7812b-20220608.png)

## 上記APIをgetStaticPropsを用いてデータを取得する
まずAPIのデータ取得処理を共通化する。
```jsx:notionApi.ts
import { databaseId, notion } from "./notion";
import { IdProps } from "./type";

//notionのqueryを取得する
export const getNotionQuery = async () => {
  const data = await notion.databases.query({
    database_id: databaseId || "",
    sorts: [
      {
        property: "created_at",
        direction: "ascending",
      },
    ],
  });
  return data;
};

//pageIdの取得
export const getNotionApiForId = async () => {
  const data = await getNotionQuery();
  const result = data.results;
  const tags = result.map((cur: any) => {
    const tag = cur.properties["ジャンル"];
    const tagName = tag.multi_select[0];
    return tagName;
  });
  const newTags: IdProps[] = tags.filter(
    (element, index, self) =>
      self.findIndex((e) => e.id === element.id) === index
  );
  return newTags;
};

//pageIdにあったpropatieを返す
export const getNotionApiFillterProperties = async (id: string) => {
  const data = await getNotionQuery();
  const result = data.results;
  const properties = result.filter((prop: any) => {
    const data = prop.properties;
    const tag = data["ジャンル"];
    const tagId = tag.multi_select[0].id;
    return tagId === id;
  });
  return properties;
};

//オブジェクトの型を簡略化する
export const getNotionApiNewObject = async (props: any) => {
  const result = props;
  const newObject = result.map((cur: any) => {
    const id = cur.id;
    const properties = cur.properties;
    return { id, properties };
  });
  return newObject;
};
```

index.tsxで運用する
```jsx:index.tsx
type IdProps = {
  id: string;
  name: string;
  color: string;
};

type Props = {
  props: IdProps[];
};

const Home: NextPage<Props> = ({ props }) => {
  return (
    <div className="flex flex-col items-center justify-center min-h-screen py-2">
      <main>
        <ul>
          {props.map((prop) => (
            <li key={prop.id} className="hover:opacity-60">
              <Link href={`/contents/${prop.id}`}>
                <a>{prop.name}</a>
              </Link>
            </li>
          ))}
        </ul>
      </main>
    </div>
  );
};

export const getStaticProps: GetStaticProps = async () => {
  const tags = await getNotionApiForId();
  return {
    props: {
      props: tags,
    },
  };
};
```
index.tsxではタグのidをルーティングIdにしてタグのIdにあったベトナム語のデータを取得する。
そしてtopページではそのリンクだけを置く

## ファイルシステムルーティングで[id].jsを定義しページを出し分ける
```jsx:[id].tsx
import { GetStaticPaths, GetStaticProps, NextPage } from "next";
import Link from "next/link";
import {
  getNotionApiFillterProperties,
  getNotionApiForId,
  getNotionApiNewObject,
} from "../../libs/notionApi";

const contents: NextPage = ({ id, data }: any) => {
  const proparties = data.map((prop: any) => {
    return prop.properties;
  });

  return (
    <>
      <div>
        <Link href="/">
          <a>戻る</a>
        </Link>
        <h1>contents</h1>
        {proparties.map((prop: any) => (
          <ul key={prop.id}>
            <li className="flex">
              <p>{prop.Name_vi.title[0].plain_text}</p>
              <p>{prop.Name_ja.rich_text[0].plain_text}</p>
            </li>
          </ul>
        ))}
      </div>
    </>
  );
};

export const getStaticPaths: GetStaticPaths = async () => {
  const tags = await getNotionApiForId();
  const paths = tags.map((tag) => `/contents/${tag.id}`);

  return {
    paths,
    fallback: false,
  };
};

export const getStaticProps: GetStaticProps = async (ctx) => {
  const id = ctx.params?.id as string;
  const propaties = await getNotionApiFillterProperties(id);
  const data = await getNotionApiNewObject(propaties);
  return {
    props: {
      id,
      data,
    },
  };
};

export default contents;

```
ファイルパスはこんな感じ
![](https://storage.googleapis.com/zenn-user-upload/3622c0b3f3a3-20220608.png)

今回スタイリングと型定義は結構適当にやったので今後いい感じに実装していきたいと思います。
## 完成
index.tsx
![](https://storage.googleapis.com/zenn-user-upload/f45eed3f6e3f-20220609.png)

[id].tsx（日常会話）
![](https://storage.googleapis.com/zenn-user-upload/b41fe512b7b5-20220609.png)
（家族）
![](https://storage.googleapis.com/zenn-user-upload/827f4189cf50-20220609.png)
スタイリングをほとんどしていないのでかなり殺風景ですが、しっかり出し分け出来ています。
## まとめ
かなり長くなってしまいましたが読んでいただきありがとうございました。
結論APIとしてpage操作やPOSTで登録はまだ直感的かと思いますがGETなどはまだまだ返り値の型がわかりにくい、SDKのメソッドが少ないなどこれからなのかなぁという印象ですがなんとか実装にはこぎつけました。
フロントのSSRとNotion　APIだけで実装したので、かなりネストが深く使いづらい印象でした。
Next.jsのAPIルートなどを使って将来的には使いやすいresponseの型にしたいと思います。
まだ、キャッチアップ途中なのでもっとこうしたほうが良いや、間違った知見などあるかもしれませんが参考に慣れば幸いです。
今回デプロイはしていませんが、もう少し学習内容が貯ればデプロイしようと思います。
皆さんもNotion APIを使用するときは是非参考にしてみて下さい。
【参考記事】

https://qiita.com/thomi40/items/fe2a828746f31ad827ba

【公式ドキュメント】

https://developers.notion.com/

【Githubリポジトリ】

https://github.com/hiroky1983/nition_api