はじめに
「Tier表」をブラウザだけで作って、URLで共有できるWebアプリを作りました。
主な特徴:
- ドラッグ&ドロップでTier表を作成
- 作ったTier表をURLに圧縮して共有(DBやストレージ不要)
- OGP画像を動的生成(SNSにURLを貼るとプレビュー画像が表示される)
- Cloudflare Workers上で動作
技術スタックは Next.js 16 (App Router) + React 19 + Tailwind CSS v4 で、デプロイ先は Cloudflare Workers です。
サンプルのTier表は以下のような感じです!!
この記事では、実装で工夫した点やCloudflare Workers特有のハマりどころについて紹介します。
アーキテクチャ
| カテゴリ | 技術 |
|---|---|
| フレームワーク | Next.js 16 (App Router) / React 19 / TypeScript 5 |
| スタイリング | Tailwind CSS v4 |
| ドラッグ&ドロップ | dnd-kit (core v6, sortable v10) |
| URL圧縮 | lz-string |
| 画像書き出し | html-to-image |
| デプロイ | Cloudflare Workers (@opennextjs/cloudflare) |
| CI/CD | GitHub Actions |
このアプリはデータベースやストレージを一切使わないサーバーレスアーキテクチャです。Tier表のデータはすべてURLのクエリパラメータに圧縮して格納するため、インフラコストはほぼゼロです。
機能紹介
タイトルの変更
Tier表にはタイトルを設定できます。タイトルをクリックすると編集モードになり、自由に名前をつけられます。
画像の追加
画像はURLで追加する方法と、ローカルファイルをアップロードする方法の2通りがあります。
ローカルからアップロードした画像は ObjectURL を使用してブラウザ内でのみ表示されるため、URL共有の際には含まれません。共有したい画像はURL指定で追加してください。
アイテムの管理
追加された画像はプールエリアに表示されます。ここからTier行へドラッグ&ドロップで配置します。
カーソルを合わせると削除ボタンが表示され、不要なアイテムを削除できます。ゴミ箱エリアにドロップすることでも削除が可能です。
Tier表の編集
画像をTier行にドラッグ&ドロップすると、このようにTier表が完成します。
Tier行は自由にカスタマイズできます。
- 行の追加・削除 — 「+」ボタンでTierを追加、「×」ボタンで削除
- 行の並べ替え — Tier行自体もドラッグで順序変更可能
- 色の変更 — カラーパレットからTierの色を変更
Tier名の変更
各Tierの名前もクリックして自由に変更できます。デフォルトはS, A, B, C, D, Eですが、用途に応じて好きな名前に変えられます。
共有・保存・リセット
作成したTier表は以下の方法で共有・保存できます。
- URLをコピー — Tier表の状態をURLに圧縮してクリップボードにコピー
- 画像として保存 — Tier表をPNG画像として書き出し(Retina対応の2倍解像度)
- リセット — Tier表を初期状態に戻す
工夫した点
1. サーバーレスURL共有 — DBなしでTier表を共有
このアプリの最大の特徴は、データベースを一切使わずにTier表を共有できることです。
圧縮の工夫:IDの除去
Tier表の各アイテムには nanoid でランダムIDが振られていますが、ランダム文字列はLZ圧縮の効率が非常に悪くなります。そこで共有時にはIDを除去し、復元時に nanoid で新しいIDを再生成するようにしました。
// エンコード時: IDを除去して最小化
const sharedData: SharedTierData = {
title: state.title,
tiers: state.tiers.map((tier) => ({
name: tier.name,
color: tier.color,
items: tier.items
.filter((item) => item.source !== "local") // ローカル画像は除外
.map(({ url, label }) => ({
url,
...(label ? { label } : {}), // ラベルがない場合はキー自体を省略
})),
})),
pool: /* 同様 */,
};
const compressed = compressToEncodedURIComponent(JSON.stringify(sharedData));
// デコード時: nanoidで新しいIDを付与
case "LOAD_FROM_URL": {
return {
title: action.payload.title,
tiers: action.payload.tiers.map((tier) => ({
...tier,
id: nanoid(),
items: tier.items.map((item) => ({
...item,
id: nanoid(),
source: "url" as const,
})),
})),
// ...
};
}
この方式により、URLの長さを大幅に削減でき、上限の32,000文字以内に収まるようになっています。
+ 文字のエスケープ問題
lz-string の出力には + が含まれることがありますが、URLのクエリパラメータでは + はスペースに変換されてしまいます。
-
クライアント側:
encodeURIComponent()で+→%2Bにエスケープ -
OGP API側:
URLSearchParams.get()は+をスペースに変換するため使わず、生URLから正規表現で直接抽出
// /api/og/route.tsx — URLSearchParams を使わずに生URLから抽出
const rawQuery = url.search.slice(1);
const dataMatch = rawQuery.match(/(?:^|&)data=([^&]*)/);
const compressed = dataMatch ? decodeURIComponent(dataMatch[1]) : null;
2. Cloudflare Workers上での純粋JS OGP画像生成
通常、Next.jsでOGP画像を生成するには next/og(内部的にはSatori + Resvg)を使いますが、Cloudflare Workers上では動作しません。WASMの読み込みに問題があるためです。
そこで、純粋なJavaScriptだけでPNG画像を生成する仕組みを自前実装しました。
やったこと
| 処理 | 通常の方法 | Workers上の代替 |
|---|---|---|
| 共有データ解凍 |
lz-string npm import |
自前のLZ decompressを実装 |
| PNG画像のデコード | Canvasや外部ライブラリ | zlibのinflateSync + PNGフィルタ復元を自前実装 |
| JPEG画像のデコード | Canvas |
jpeg-js ライブラリ |
| テキスト描画 | Canvas / Satori | 5×7ビットマップフォントを自前定義 |
| PNG画像のエンコード | Canvas.toBuffer() / Resvg | CRC32, IHDR, IDAT チャンク生成を自前実装 |
| 画像リサイズ | Canvas | Center cropのnearest neighbor補間を自前実装 |
PNGデコーダの実装
PNG仕様に従って、シグネチャ検証 → チャンク解析(IHDR, IDAT, IEND)→ zlib展開 → フィルタ復元(None, Sub, Up, Average, Paeth)を行います。
function decodePng(buf: Uint8Array): DecodedImage | null {
// PNG signature check: 137 80 78 71 ...
if (buf[0] !== 137 || buf[1] !== 80 || buf[2] !== 78 || buf[3] !== 71) {
return null;
}
// チャンクを順に解析
while (offset < buf.length) {
const chunkType = /* IHDR, IDAT, IEND */;
if (chunkType === "IHDR") { /* 画像サイズ、色深度を取得 */ }
else if (chunkType === "IDAT") { /* 圧縮データを収集 */ }
else if (chunkType === "IEND") { break; }
}
// IDATチャンクを結合してzlib展開
const inflated = new Uint8Array(inflateSync(Buffer.from(combined)));
// PNGフィルタの復元 (Paeth予測など)
for (let y = 0; y < height; y++) {
const filterType = inflated[offset++];
switch (filterType) {
case 0: break; // None
case 1: val = (val + a) & 0xff; break; // Sub
case 2: val = (val + b) & 0xff; break; // Up
case 3: val = (val + ((a+b)>>1)) & 0xff; break; // Average
case 4: val = (val + paethPredictor(a,b,c)) & 0xff; break; // Paeth
}
}
}
ビットマップフォント
Canvasやフォントファイルが使えないため、ASCII文字を5×7ピクセルのビットマップで定義し、ピクセル単位で描画しています。
const FONT_5X7: Record<string, number[]> = {
A: [14, 17, 17, 31, 17, 17, 17],
B: [30, 17, 17, 30, 17, 17, 30],
// ... 各文字をビットパターンで定義
};
// ビットマスクでピクセルを打つ
if (glyph[row] & (1 << (4 - col))) {
pixels[idx] = r;
pixels[idx + 1] = g;
pixels[idx + 2] = b;
}
日本語には非対応ですが、Tier名(S, A, B...)やタイトルの表示には十分です。
3. dnd-kitのマルチデバイス対応
ドラッグ&ドロップには @dnd-kit を使用していますが、PC・スマホの両方で快適に動作させるためにいくつかの工夫をしています。
センサーの分離
PointerSensor を使うとタッチデバイスでスクロールとドラッグが干渉する問題があったため、MouseSensor と TouchSensor を別々に設定しました。
const mouseSensor = useSensor(MouseSensor, {
activationConstraint: { distance: 5 },
});
const touchSensor = useSensor(TouchSensor, {
activationConstraint: { delay: 250, tolerance: 8 },
});
const keyboardSensor = useSensor(KeyboardSensor);
const sensors = useSensors(mouseSensor, touchSensor, keyboardSensor);
- MouseSensor: 5pxの移動で発火(誤クリック防止)
- TouchSensor: 250msの長押しで発火、8pxの許容範囲(スクロールとの分離)
- KeyboardSensor: アクセシビリティ対応
衝突検知アルゴリズム
closestCorners だとアイテムの中心でしか反応しない問題があったため、rectIntersection(矩形の重なりで判定)を採用しました。これにより、アイテムの端がTier行に少しでもかかれば反応するようになっています。
4. 状態管理: useReducer パターン
このアプリの状態管理には useReducer を採用しています。Reduxのような外部ライブラリは使わず、React標準のフックだけで10種類のアクションを管理しています。
ADD_ITEM — アイテム追加
REMOVE_ITEM — アイテム削除
MOVE_ITEM — アイテム移動(D&Dの核心)
ADD_TIER — Tier行追加
REMOVE_TIER — Tier行削除(アイテムはプールに戻る)
MOVE_TIER — Tier行の並べ替え
UPDATE_TIER — Tier名・色の更新
SET_TITLE — タイトル変更
RESET — 全リセット
LOAD_FROM_URL — URL共有データからの復元
Tier削除時にアイテムが消えないよう、削除されたTierのアイテムは自動的にプールに戻されます。
CI/CDパイプライン
GitHub Actionsで main ブランチへのpush時に自動デプロイしています。
name: Deploy to Cloudflare Workers
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- run: npm ci
- run: npm run build:cloudflare # @opennextjs/cloudflare でビルド
- run: npx wrangler deploy # Cloudflare Workers にデプロイ
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
@opennextjs/cloudflare がNext.jsのビルド出力をCloudflare Workers形式に変換し、wrangler でデプロイします。5ステップのシンプルなパイプラインです。
Cloudflare Workers でハマったこと
開発中に遭遇したCloudflare Workers固有の問題をまとめます。
lz-string のnpm importが失敗する
Workers環境で lz-string をimportすると実行時エラーになりました。原因はWorkers特有のモジュール解決の挙動によるもので、OGP API(/api/og)ではデータ解凍が必要なため、decompressFromEncodedURIComponent を約200行で自前実装しました。
クライアント側では通常通り lz-string を使用しています。
next/og (Satori + Resvg) が動作しない
next/og の ImageResponse はWASM(Resvg)に依存していますが、Next.jsのバンドラがWASMファイルを正しく処理できず、Workersで500エラーになります。@resvg/resvg-wasm を直接使う方法も試しましたが、同じ問題が発生しました。
最終的に、前述の純粋JS PNG生成で完全に代替しています。
export const runtime = "edge" を書くと500エラー
通常、Edge Runtimeを指定するために export const runtime = "edge" を書きますが、@opennextjs/cloudflare を使う場合はこの宣言が不要で、書くと逆に500エラーになります。OpenNextがランタイムの管理を行うため、任せるのが正解です。
まとめ
- サーバーレスURL共有: lz-stringでTier表全体をURLに圧縮。IDを除去する工夫でURL長を大幅に削減
- 純粋JS OGP画像生成: Cloudflare Workersの制約を回避するため、PNGデコーダ/エンコーダ・ビットマップフォントをすべて自前実装
- マルチデバイスD&D: dnd-kitのセンサーを分離し、PCとスマホの両方で快適な操作を実現
- シンプルなCI/CD: GitHub Actions + wrangler で5ステップの自動デプロイ
Cloudflare WorkersはVercelと比べて制約が多い分、「どうやって純粋なJSだけで実現するか」を考える良いチャレンジになりました。特にPNG生成の自前実装はPNG仕様への理解が深まり、とても勉強になりました。
GitHub
GitHubにコードを公開しています。
追記
2026/04/22
未配置アイテムを選択して編集できる機能を追加








