6
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Next.js × Cloudflare Workers】サーバーレスなTier表メーカーを作った

6
Last updated at Posted at 2026-04-01

はじめに

「Tier表」をブラウザだけで作って、URLで共有できるWebアプリを作りました。

image.png

主な特徴:

  • ドラッグ&ドロップでTier表を作成
  • 作ったTier表をURLに圧縮して共有(DBやストレージ不要)
  • OGP画像を動的生成(SNSにURLを貼るとプレビュー画像が表示される)
  • Cloudflare Workers上で動作

技術スタックは Next.js 16 (App Router) + React 19 + Tailwind CSS v4 で、デプロイ先は Cloudflare Workers です。

サンプルのTier表は以下のような感じです!!

この記事では、実装で工夫した点やCloudflare Workers特有のハマりどころについて紹介します。

アーキテクチャ

image.png

カテゴリ 技術
フレームワーク 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表にはタイトルを設定できます。タイトルをクリックすると編集モードになり、自由に名前をつけられます。

image.png

画像の追加

画像はURLで追加する方法と、ローカルファイルをアップロードする方法の2通りがあります。

image.png

ローカルからアップロードした画像は ObjectURL を使用してブラウザ内でのみ表示されるため、URL共有の際には含まれません。共有したい画像はURL指定で追加してください。

アイテムの管理

追加された画像はプールエリアに表示されます。ここからTier行へドラッグ&ドロップで配置します。

image.png

カーソルを合わせると削除ボタンが表示され、不要なアイテムを削除できます。ゴミ箱エリアにドロップすることでも削除が可能です。

Tier表の編集

画像をTier行にドラッグ&ドロップすると、このようにTier表が完成します。

image.png

Tier行は自由にカスタマイズできます。

  • 行の追加・削除 — 「+」ボタンでTierを追加、「×」ボタンで削除
  • 行の並べ替え — Tier行自体もドラッグで順序変更可能
  • 色の変更 — カラーパレットからTierの色を変更

Tier名の変更

各Tierの名前もクリックして自由に変更できます。デフォルトはS, A, B, C, D, Eですが、用途に応じて好きな名前に変えられます。

image.png

共有・保存・リセット

作成したTier表は以下の方法で共有・保存できます。

image.png

  • URLをコピー — Tier表の状態をURLに圧縮してクリップボードにコピー
  • 画像として保存 — Tier表をPNG画像として書き出し(Retina対応の2倍解像度)
  • リセット — Tier表を初期状態に戻す

工夫した点

1. サーバーレスURL共有 — DBなしでTier表を共有

このアプリの最大の特徴は、データベースを一切使わずにTier表を共有できることです。

image.png

圧縮の工夫: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 を使うとタッチデバイスでスクロールとドラッグが干渉する問題があったため、MouseSensorTouchSensor を別々に設定しました。

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/ogImageResponse は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

未配置アイテムを選択して編集できる機能を追加

6
7
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?