Notion APIを使ってブログを構築する際、テーブル(表)のレンダリングは意外とハマりやすいポイントです。この記事では、Notionのテーブルデータ構造を理解し、HTMLに変換する方法を解説します。
参考ドキュメント


Notionのテーブルは、以下のような親子関係を持つブロック構造になっています:
table (親ブロック)
├── table_row (子ブロック)
├── table_row (子ブロック)
└── table_row (子ブロック)
重要なポイントは、tableブロック自体にはセルのデータが含まれていないことです。セルのデータは子ブロックであるtable_rowに格納されています。
{
"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で変更しようとしても失敗します。
{
"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") |
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ブロック内で処理されるため、単独で変換関数が呼ばれた場合は空文字を返します:
case 'table_row': {
// table 内で処理されるため、単独では空を返す
return '';
}
tableブロックは必ずhas_children: trueですが、再帰的な子ブロック取得を汎用的に実装している場合、テーブルの処理が二重になる可能性があります。
// 問題のあるコード:汎用的な子ブロック処理
if (block.has_children) {
const childBlocks = await getAllBlocks(block.id);
for (const childBlock of childBlocks) {
// table_row も個別に処理されてしまう
childrenHtml += await convertBlockToHtml(childBlock);
}
}
解決策:tableケース内で独自に子ブロックを処理し、table_rowの単独処理では空を返す。
セルはArray<Array<RichTextItem>>という二重配列です:
cells[セルインデックス][リッチテキストアイテムインデックス]
単純にplain_textを取得するだけでも、ネストを意識する必要があります。
Notionでは「列ヘッダー」と「行ヘッダー」の両方をサポートしています:
両方がtrueの場合、左上のセルは両方の条件に該当します。
// 行ヘッダーと列ヘッダーの組み合わせ
const isHeaderRow = hasColumnHeader && rowIndex === 0;
const isRowHeaderCell = hasRowHeader && cellIndex === 0;
if (isHeaderRow || isRowHeaderCell) {
// <th> を使用
}
Next.jsのISR(Incremental Static Regeneration)やReactのcache()を使用している場合、コード修正後もキャッシュされたコンテンツが表示されることがあります。
開発時の確認では:
を試してみてください。
今回の実装ではplain_textのみを抽出していますが、完全な対応には以下も必要です:
アノテーション(annotations):
リッチテキストタイプ:
// より完全な実装例
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('');
レスポンシブ対応と見やすさのために、以下のスタイルを推奨します:
<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>
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のテーブル処理で押さえるべきポイント:
これらを理解すれば、Notionのテーブルを美しくHTMLにレンダリングできます。


