Notion API でテーブルをHTMLにレンダリングする方法
Notion APIを使ってブログを構築する際、テーブル(表)のレンダリングは意外とハマりやすいポイントです。この記事では、Notionのテーブルデータ構造を理解し、HTMLに変換する方法を解説します。
参考ドキュメント
Block - Notion Docs
A block object represents a piece of content within Notion. The API translates the headings, toggles, paragraphs, lists, media, and more that you can interact with in the Notion UI as different block type objects.

Rich text - Notion Docs
Notion uses rich text to allow users to customize their content. Rich text refers to a type of document where content can be styled and formatted in a variety of customizable ways. This includes styling decisions, such as the use of italics, font size, and font color, as well as formatting, such as the use of hyperlinks or code blocks.

Notion API のテーブルデータ構造
ブロックの親子関係
Notionのテーブルは、以下のような親子関係を持つブロック構造になっています:
plain text
table (親ブロック)
├── table_row (子ブロック)
├── table_row (子ブロック)
└── table_row (子ブロック)
重要なポイントは、tableブロック自体にはセルのデータが含まれていないことです。セルのデータは子ブロックであるtable_rowに格納されています。
table ブロックの構造
json
{
"id": "block-id",
"type": "table",
"has_children": true,
"table": {
"table_width": 4,
"has_column_header": true,
"has_row_header": false
}
}
| プロパティ | 型 | 説明 |
|---|---|---|
table_width | integer | テーブルの列数。作成後は変更不可 |
has_column_header | boolean | trueの場合、1行目が視覚的に区別されて表示される |
has_row_header | boolean | trueの場合、1列目が視覚的に区別されて表示される |
注意: table_widthはテーブル作成時にのみ設定可能です。Update block APIで変更しようとしても失敗します。
table_row ブロックの構造
json
{
"id": "row-block-id",
"type": "table_row",
"table_row": {
"cells": [
[
{
"type": "text",
"text": { "content": "項目", "link": null },
"annotations": {
"bold": false,
"italic": false,
"strikethrough": false,
"underline": false,
"code": false,
"color": "default"
},
"plain_text": "項目",
"href": null
}
],
[{ "type": "text", "plain_text": "Before", "..." : "..." }],
[{ "type": "text", "plain_text": "After", "..." : "..." }]
]
}
}
cellsの構造:
- 外側の配列: 列(水平方向の表示順)
- 内側の配列: 各セル内のリッチテキストオブジェクト
公式ドキュメントより:
"An array of cell contents in horizontal display order. Each cell is an array of rich text objects."
これにより、1つのセルに複数のテキストスタイル(太字、リンクなど)を混在させることができます。
リッチテキストオブジェクトの構造
各セル内のリッチテキストオブジェクトは以下のプロパティを持ちます:
| プロパティ | 説明 |
|---|---|
type | "text", "mention", "equation" のいずれか |
plain_text | フォーマットなしのテキスト(開発者向けの便利プロパティ) |
href | リンクURLまたはnull |
annotations | スタイリング情報(下記参照) |
annotations オブジェクト:
| プロパティ | 型 | 説明 |
|---|---|---|
bold | boolean | 太字 |
italic | boolean | イタリック |
strikethrough | boolean | 取り消し線 |
underline | boolean | 下線 |
code | boolean | インラインコード |
color | enum | テキスト色または背景色(例: "blue", "green_background") |
HTMLへの変換実装
基本的な変換ロジック
typescript
case 'table': {
const tableData = block.table;
const hasColumnHeader = tableData?.has_column_header || false;
const hasRowHeader = tableData?.has_row_header || false;
// 子ブロック(table_row)を取得
const childBlocks = block.has_children
? await getAllBlocks(block.id)
: [];
let rowsHtml = '';
childBlocks.forEach((childBlock, rowIndex) => {
if (childBlock.type === 'table_row') {
const cells = childBlock.table_row?.cells || [];
const isHeaderRow = hasColumnHeader && rowIndex === 0;
const cellsHtml = cells.map((cell, cellIndex) => {
const cellText = cell
.map(t => t.plain_text || '')
.join('');
const escapedText = escapeHtmlContent(cellText);
const isRowHeaderCell = hasRowHeader && cellIndex === 0;
// ヘッダー行または行ヘッダーの場合は <th>
if (isHeaderRow || isRowHeaderCell) {
return `<th>${escapedText}</th>`;
}
return `<td>${escapedText}</td>`;
}).join('');
rowsHtml += `<tr>${cellsHtml}</tr>`;
}
});
return `<table><tbody>${rowsHtml}</tbody></table>`;
}
table_row の単独処理
table_rowブロックはtableブロック内で処理されるため、単独で変換関数が呼ばれた場合は空文字を返します:
typescript
case 'table_row': {
// table 内で処理されるため、単独では空を返す
return '';
}
ハマりどころ・難しいポイント
1. has_children フラグの罠
tableブロックは必ずhas_children: trueですが、再帰的な子ブロック取得を汎用的に実装している場合、テーブルの処理が二重になる可能性があります。
typescript
// 問題のあるコード:汎用的な子ブロック処理
if (block.has_children) {
const childBlocks = await getAllBlocks(block.id);
for (const childBlock of childBlocks) {
// table_row も個別に処理されてしまう
childrenHtml += await convertBlockToHtml(childBlock);
}
}
解決策:tableケース内で独自に子ブロックを処理し、table_rowの単独処理では空を返す。
2. セルデータのネスト構造
セルはArray<Array<RichTextItem>>という二重配列です:
plain text
cells[セルインデックス][リッチテキストアイテムインデックス]
単純にplain_textを取得するだけでも、ネストを意識する必要があります。
3. ヘッダーの判定ロジック
Notionでは「列ヘッダー」と「行ヘッダー」の両方をサポートしています:
- 列ヘッダー(has_column_header): 1行目全体がヘッダー
- 行ヘッダー(has_row_header): 各行の1列目がヘッダー
両方がtrueの場合、左上のセルは両方の条件に該当します。
typescript
// 行ヘッダーと列ヘッダーの組み合わせ
const isHeaderRow = hasColumnHeader && rowIndex === 0;
const isRowHeaderCell = hasRowHeader && cellIndex === 0;
if (isHeaderRow || isRowHeaderCell) {
// <th> を使用
}
4. キャッシュの影響
Next.jsのISR(Incremental Static Regeneration)やReactのcache()を使用している場合、コード修正後もキャッシュされたコンテンツが表示されることがあります。
開発時の確認では:
- サーバーの再起動
.nextディレクトリの削除- ブラウザのハードリロード
を試してみてください。
5. リッチテキストの完全対応
今回の実装ではplain_textのみを抽出していますが、完全な対応には以下も必要です:
アノテーション(annotations):
bold→<strong>italic→<em>underline→<u>strikethrough→<s>code→<code>color→ インラインスタイルまたはCSSクラス
リッチテキストタイプ:
text→ 通常テキスト(hrefプロパティでリンク対応)mention→ ユーザー、ページ、データベース、日付、リンクプレビューへの参照equation→ LaTeX形式の数式(expressionプロパティに格納)
typescript
// より完全な実装例
const cellHtml = cell.map(item => {
let text = escapeHtmlContent(item.plain_text || '');
// アノテーションの適用
const { annotations } = item;
if (annotations?.code) {
text = `<code>${text}</code>`;
}
if (annotations?.bold) {
text = `<strong>${text}</strong>`;
}
if (annotations?.italic) {
text = `<em>${text}</em>`;
}
if (annotations?.underline) {
text = `<u>${text}</u>`;
}
if (annotations?.strikethrough) {
text = `<s>${text}</s>`;
}
// リンクの適用
if (item.href) {
text = `<a href="${escapeHtmlAttribute(item.href)}">${text}</a>`;
}
// 数式の場合は特別処理(KaTeXなどを使用)
if (item.type === 'equation') {
text = `<span class="math">${escapeHtmlContent(item.equation?.expression || '')}</span>`;
}
return text;
}).join('');
スタイリングのヒント
レスポンシブ対応と見やすさのために、以下のスタイルを推奨します:
html
<div class="overflow-x-auto my-4">
<table class="w-full border-collapse border border-slate-300">
<tbody>
<tr>
<th class="border border-slate-300 px-3 py-2 text-left font-semibold bg-slate-50">
ヘッダー
</th>
<td class="border border-slate-300 px-3 py-2">
データ
</td>
</tr>
</tbody>
</table>
</div>
overflow-x-auto: 横スクロール対応(モバイル向け)border-collapse: セル間の隙間をなくす- ヘッダーに背景色を付けて視認性向上
6. テーブル作成時の制約(API経由)
Notion APIでテーブルを新規作成する場合、以下の制約があります:
"the table must have at least one table_row whose cells array has the same length as the table_width"
つまり、テーブル作成時には少なくとも1行が必要で、その行のセル数はtable_widthと一致していなければなりません。
まとめ
上記の要領で変換処理を実装することで、HTMLデータとして以下のように表が表示できるようになりました!🎉
※内容はこの記事とはなんら関係ありません。
| 項目 | Before | After | 改善 |
|---|---|---|---|
| SSD容量 | 476.9GB | 931.5GB | +454.6GB |
| Ubuntuパーティション | 146.5GB | 601.1GB | +310% |
| 空き容量 | 4.5GB | 435GB | +430.5GB |
| 使用率 | 97% 🔴 | 24% 🟢 | -73pt |
Notion APIのテーブル処理で押さえるべきポイント:
- 親子関係を理解する:
tableとtable_rowは別ブロック - cells は二重配列: リッチテキストのネスト構造に注意
- ヘッダーは2種類: 列ヘッダーと行ヘッダーの両方に対応
- 単独処理を防ぐ:
table_rowが個別に処理されないよう設計 - キャッシュに注意: 開発時は適宜キャッシュをクリア
- table_widthは不変: 作成後の列数変更は不可
これらを理解すれば、Notionのテーブルを美しくHTMLにレンダリングできます。
参考リンク
Block - Notion Docs
A block object represents a piece of content within Notion. The API translates the headings, toggles, paragraphs, lists, media, and more that you can interact with in the Notion UI as different block type objects.

Block - Notion Docs
A block object represents a piece of content within Notion. The API translates the headings, toggles, paragraphs, lists, media, and more that you can interact with in the Notion UI as different block type objects.

Retrieve block children - Notion Docs
Returns a paginated array of child block objects contained in the block using the ID specified. In order to receive a complete representation of a block, you may need to recursively retrieve the block children of child blocks.
