🍣

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

2022/06/09に公開

使用技術

  • Next.js
  • TypeScript
  • Notion SDK JS
  • TailiwindCSS

この記事を書いたきっかけ

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

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

アプリの設計イメージ

アプリで出来ること

NotionアプリのテーブルビューをそのままNotion APIを用いてDBとしてデータを保存し、CMSとしてアプリ側で閲覧出来るようにする。

ページの全体像

Notionの該当ページはこんな感じ。

このページからテーブルのカラムを取得してページでデータを表示する。

テーブルカラムの役割

テーブル名 カラムの型 役割
Name_vi タイトル ベトナム語
Name_ja テキスト 該当ベトナム語の日本語訳
ジャンル マルチセレクト タグ付け、ページのルーティングにも使用
created_at 作成日時 テーブルの情報を取得する条件に使用

環境構築

Notionのintegrationの作成

インテグレーションページ
https://www.notion.so/my-integrations
1.新しいインテグレーションを作成するを押す

2.アプリの名前やワークスペース、権限の設定を行う

私の場合は今回は読み取り専用で使いたいのでコンテンツ機能のチェックボックスは一番上だけチェックしておきます。
3.設定が出来れば下部の送信ボタンを押す
4.設定が出来ればシークレットトークンをコピーします

Notionアプリ側の設定

1.Notionの画面右上の共有ボタンを押す

2.自分の作成したインテグレーションを選択する

Notion側の設定は以上です。

NotionのSDKを使う

公式のGithubリポジトリ

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

SDKのインストール

npm install @notionhq/client

//or

yarn add @notionhq/client

エディタの設定

API_URLの設定方法

https://www.notion.so/xxxxxxxxxxxx/a8aec43384f447ed84390e8e42c2e089?v=...
                                  |--------- Database ID --------|
.env
NEXT_PUBLIC_API_KEY=secret_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
NEXT_PUBLIC_API_URL=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

API_URLは上記のDatabase IDを貼りつけて下さい。
NEXT_PUBLIC_API_KEYはインテグレーションからコピーしたトークンを貼り付けて下さい。

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を叩いていく

メソッドの実行

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

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

返り値

{
  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を使うらしいです。(このあたりは直感的にわかりにくい??)

改めて書き直したメソッド

//apiを叩くメソッド
const data = await notion.databases.query({
    database_id: databaseId || "",
    sorts: [
      {
        property: "created_at",
        direction: "ascending",
      },
    ],
  });

queryメソッドがsortのように取得条件を指定して並び順を指定しないと上手く取得出来ないようです。

再度返り値の型

//返り値の型
{
 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'
    },
}

ん〜よくわからんww
が、こちらにもpropertiesがあるのでアクセスしてみる。

//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;
  });

再再度返り値の型

//返り値の型
  {
    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

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

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を使うのが良いかと思われます。

ジャンル

multi_select: Array(1)
0:
color: "blue"
id: "xxxx"
name: "日常会話"

これはtagの型ですがmulti_selectという形で返って来ます。
これのidを使ってルーティングを動的に出し分けます。

ちなみにpropertiesは型はなくTSエラーになる。。。

NotionのSDKを使うと、レスポンスの型がQueryDatabaseResponseという名前で登録されているが。なぜかネストが深くなるとpropertiesの型はなく、基本的にidとobjectしか補完が効かない。。
私みたいな使い方はまだ想定されていないということでしょうか??
とりあえず、解決出来なかったのでanyに(トホホ、、、、)

上記APIをgetStaticPropsを用いてデータを取得する

まずAPIのデータ取得処理を共通化する。

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で運用する

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を定義しページを出し分ける

[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;

ファイルパスはこんな感じ

今回スタイリングと型定義は結構適当にやったので今後いい感じに実装していきたいと思います。

完成

index.tsx

[id].tsx(日常会話)

(家族)

スタイリングをほとんどしていないのでかなり殺風景ですが、しっかり出し分け出来ています。

まとめ

かなり長くなってしまいましたが読んでいただきありがとうございました。
結論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

Discussion