💬

liveblocks入門した

に公開

https://liveblocks.io/docs/get-started/nextjs

Liveblocksとは何か

Liveblocks は、アプリケーションにリアルタイムコラボレーション機能を簡単に組み込むためのクラウドサービスです。ドキュメントエディタ、ホワイトボード、グラフィックツールなどで複数ユーザーが同時に編集や操作を行う際、リアルタイムで変更が反映され、他のユーザーの存在やカーソル位置などを表示できます。

主な特徴・機能

  1. リアルタイム同期 (Real-time Collaboration)

    • サーバーを通じてクライアント間でデータの変更をリアルタイムに同期
    • 反映の遅延が少なく、ストレスを感じにくい
  2. Presence(プレゼンス)の管理

    • 各ユーザーのカーソル位置や、現在どの部分を操作しているかなどを可視化
    • 「誰がオンラインか」「何をしているか」をリアルタイムに把握できる
  3. 開発向けライブラリとSDK

    • React向けのライブラリなどが提供されており、短いコード量でコラボレーション機能を追加可能
    • TypeScriptなどもサポートされている
  4. Conflict Resolution(競合解消)

    • 同時編集時の競合を自動的に解消する仕組みが備わっているため、自前で難易度の高いアルゴリズムを実装する必要がない
  5. セキュリティ・認証

    • プライベートなデータを保護しつつ、リアルタイム通信を実現する仕組み
    • 認証やアクセス制限機能が提供されている

強み

  • 実装の容易さ
    リアルタイム通信や競合解決といった複雑な部分が抽象化されており、少ないコード量で導入できる。

  • 開発者体験 (DX) が良い
    Reactなどのモダンなフロントエンドフレームワークに対する公式ライブラリやチュートリアルが用意されているため、導入・学習コストが比較的低い。

  • 拡張性
    単純なテキストエディタに限らず、ホワイトボード、グラフィックエディタなどさまざまなタイプのアプリケーションに対応しやすい。


弱み

  • 有償モデルである点
    商用利用の場合はプランの費用を検討する必要がある。フリー枠があるものの、ユーザー数や同時接続数が増えると有料プランが必要になる。

  • ベンダーロックインのリスク
    Liveblocksのサービスに依存する形になるため、自前でサーバーを用意したい場合や、オンプレミス環境で完結させたい場合には不向き。

  • OSSのリアルタイムフレームワークとの比較
    CRDTライブラリ(例: Y.js など)やAutomergeなどを自分でホスティングする場合と比べて、細かなカスタマイズが難しい可能性がある。


競合ソリューション

  • Firebase (Cloud Firestore) / Supabase Realtime

    • リアルタイムデータ同期やホスティングを総合的に提供。
    • 独自のリアルタイム通信や認証機能があるが、プレゼンスやカーソル共有など特化した機能を自前で作り込む必要がある。
  • Pusher / Ably

    • パブリッシュ・サブスクライブ (Pub/Sub) 型のリアルタイム通信基盤を提供。
    • プレゼンス機能はあるが、競合解消などは自前で実装が必要。
  • CRDTライブラリ (Y.js, Automergeなど)

    • オープンソースで柔軟性が高いが、ホスティングや運用の手間がかかる。
    • リアルタイム通信のためのサーバーや認証、アクセス制御などの実装が必要。
  • Liveblocks以外のSaaS系コラボレーションプラットフォーム

    • 他の専業プロバイダも存在するが、Liveblocksは日本語情報が比較的少ない一方、欧米圏での利用事例が多い。

以下では、見やすさを重視したMarkdown形式のテーブルを提示します。セル内で箇条書きや改行を使うことで、比較項目が整理され、視認性が高まるように工夫しています。Zennなどのエディタに貼り付けても、比較的読みやすい形になるはずです。


サービス 主な用途 性能(遅延/同時接続など) 価格(課金体系) 特徴的な機能 メリット デメリット
Liveblocks リアルタイム共同編集機能をSaaSで提供 ・比較的低遅延
・同時接続・ユーザー数が増えると上位プランが必要
・無料枠あり
・接続数やイベント量に応じて有償プラン
・共同編集時のコンフリクト解決・プレゼンス管理を標準提供
・カーソル共有などが簡単に実装可能
・リアルタイム共同編集に特化
・実装コストが低い
・SaaSでメンテ不要
・有償プランへの移行が必要な場合がある
・特定要件外の拡張が難しい場合がある
Firebase
(Cloud Firestore)
・モバイル/Webアプリのバックエンド全般 ・グローバルに最適化されたインフラ
・通信遅延はリージョンに依存
・無料枠あり
・DB読み書きや同時接続数が増えると従量課金
・リアルタイムDB、ホスティング、認証、ストレージなど
・多彩なBaaS機能をワンストップで提供
・総合的なBaaS機能
・豊富なドキュメントやコミュニティ
・無料枠も比較的大きい
・Firestoreのクエリ制限
・大規模利用で料金が上がる可能性
Supabase
Realtime
・OSSのFirebase代替を目指す ・Postgresリスナーを利用
・拡張性と安定性はそこそこ
・無料プランあり
・使った分だけの従量課金
・上限超過で有料プランへ
・OSSとして自前ホスト可能
・エッジファンクションやAuthなど、Firebase類似の総合機能
・OSSでカスタマイズ可能
・ホスト場所を細かく制御可
・料金体系がシンプル
・自前運用時のメンテ負荷
・日本語情報が少ない
Pusher/Ably ・パブリッシュ/サブスクライブ型のリアルタイム通信基盤 ・世界各地のエッジサーバーで低遅延
・大規模負荷にも対応しやすい
・無料枠あり
・メッセージ数、チャネル数、接続数に応じた従量課金
・Presence機能やWebSocket通信管理が充実
・大規模チャットやメッセージングに強い
・高速かつグローバル対応
・シンプルなPub/Subモデル
・チャット用途に特化
・利用規模が大きいと費用が増加
・競合解消など同時編集ロジックは自前で実装必要
CRDTライブラリ
(Y.js / Automerge等)
・RDTを活用したOSSのリアルタイム共同編集フレームワーク ・サーバー構成やネットワークに依存
・最適化次第で高速化可能
・無料(OSS)
・サーバー運用やホスティングの費用は自己負担
・CRDTによる競合解消が自動
・テキスト以外の共同編集にも応用可能
・OSSでライセンス無料
・カスタマイズ自由
・競合解消が自動的に行われる
・全機能を自前開発・運用
・大規模化でインフラ設計が複雑化
・メンテナンスコストが増大

補足

  • 価格や性能などは利用形態・ユーザー数・アクセス頻度によって変動するため、詳細な要件を踏まえて検討してください。
  • Liveblocks はリアルタイム共同編集機能に特化しており、プレゼンス管理や競合解消などを最小限の実装で実現できるのが強みです。
  • FirebaseSupabase は認証、ホスティング、DB機能などを網羅したBaaS的なアプローチであり、バックエンドを総合的に任せたい場合に選択肢に挙がります。
  • CRDTライブラリ は自由度が高い反面、すべてを自前実装・運用する必要があり、規模や要件によっては管理コストが増大します。

どのような時にLiveblocksを導入すべきか

  • 迅速にリアルタイム共同編集機能を実装したい時
    コア機能としての競合解消やプレゼンスなどを任せられるため、開発スピードを重視する場合に有効。

  • 小規模スタートで、ユーザー数がある程度想定される場合
    無料枠から始めて、必要に応じて上位プランに移行できる。サーバー運用の手間がないので運用コストを抑えつつスケール可能。

  • Reactベースのフロントエンドを活用している場合
    公式提供のライブラリ(@liveblocks/client@liveblocks/reactなど)があり、スムーズに導入できる。

  • エンタープライズ向けであっても、外部サービスを使える環境
    アプリケーションの機密性次第だが、外部のSaaSを利用しても問題ない要件であれば、インフラやセキュリティの負担を軽減できる。


下準備

以下の記事を参照してdemoを作成してみましょう。
https://liveblocks.io/docs/get-started/nextjs

以下をターミナルに入力しましょう。

npx create-next-app@latest  --typescript

Ok to proceed? (y) y
✔ What is your project named? … sample-reaction
✔ Would you like to use ESLint? … No / Yes
✔ Would you like to use Tailwind CSS? … No / Yes
✔ Would you like your code inside a `src/` directory? … No / Yes
✔ Would you like to use App Router? (recommended) … No / Yes
✔ Would you like to use Turbopack for `next dev`? … No / Yes
✔ Would you like to customize the import alias (`@/*` by default)? … No / Yes
cd sample-reaction

Liveblocksのアカウント登録とAPIキーの取得

  1. Liveblocks公式サイトにアクセスし、アカウントを作成します(無料プランあり)。
  2. ダッシュボードにログインしたら、右上の + Create Prohect ... でプロジェクトを作成(今回はNamesample-reactionEnvironmentDevelopmentとした。)

https://liveblocks.io/

注意: APIキーはセキュリティ上、公開しないように扱いましょう。環境変数ファイル(.envなど)に保存するのが一般的です。


必要なLiveblocksパッケージのインストール

Next.js プロジェクトのルートディレクトリで以下を実行して、Liveblocksを利用するためのライブラリをインストールします。

npm install @liveblocks/client  @liveblocks/node  @liveblocks/zustand @liveblocks/react
npx create-liveblocks-app@latest --init

公式ドキュメントでは @liveblocks/client などが推奨されています。状態管理にZustandを使う場合は@liveblocks/zustandも導入すると便利です。


環境変数の設定

Liveblocksの秘密鍵(APIキー)を環境変数に設定します。Next.js の場合、ルートディレクトリに .env.local ファイルを作成して管理しましょう。
Liveblocks公式サイトの左側にあるAPI keysのタブを選択し、Public keyをコピーしてください。
以下のファイルを作成し、環境変数を設定してください。

.env.local
NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY=pk_dev_xxxxxxxxxxxxxxxxxxxxxxxxx

ここに実際のAPIキーを貼り付けてください。Gitなどのリポジトリに .env.local は含めないように .gitignore を設定しておきましょう。

globals.cssの編集

以下に上書きしてください。

app/globals.css
@import "tailwindcss";

コラボレーター数の表示

ファイル構成

sample-reaction
├── app              # LiveblocksのAPIキーなどの環境変数を設定          # 例: Faviconなどの静的ファイル
│   └── globals.css              # メインページ (サンプル: カーソル同期を実装)
│   └── CollaborativeApp.tsx 
│   └── layout.tsx
│   └── page.tsx
│   └── Room.tsx
├── .env.local      # LiveblocksのAPIキーなどの環境変数を設定
└── ...(その他、必要に応じて追加)

Liveblocksのルーム作成

Liveblocksは、ユーザーがコラボレーションするための仮想空間である「ルーム」の概念を使用します。Next.jsの/appルーターを使用する場合、現在のルートと同じディレクトリにRoom.tsxファイルを作成することをお勧めします。

app/Room.tsx
"use client";

import { ReactNode } from "react";
import {
  LiveblocksProvider,
  RoomProvider,
  ClientSideSuspense,
} from "@liveblocks/react/suspense";

export function Room({ children }: { children: ReactNode }) {
  return (
    <LiveblocksProvider publicApiKey={"pk_dev_P8k6TedprqunU8yB9gLNzLKDec8jakqBsfSzvkaKaktf32FINGT0Rw5qM8bqAwV7"}>
      <RoomProvider id="my-room">
        <ClientSideSuspense fallback={<div>Loading…</div>}>
          {children}
        </ClientSideSuspense>
      </RoomProvider>
    </LiveblocksProvider>
  );
}

ページへのLiveblocksルームの追加

Room.tsxファイルを作成した後、それをpage.tsxファイルにインポートし、コラボレーションアプリのコンポーネントをその中に配置します。

app/page.tsx
import { Room } from "./Room";
import { CollaborativeApp } from "./CollaborativeApp";

export default function Page() {
 return (
   <Room>
     <CollaborativeApp />
   </Room>
 );
}

Liveblocksフックの使用

ルームに接続した後、useOthersフックを使用して、ルームに接続している他のユーザーに関する情報を取得できます。

app/CollaborativeApp.tsx
"use client";

import { useOthers } from "@liveblocks/react/suspense";

export function CollaborativeApp() {
  const others = useOthers();
  const userCount = others.length;
  return <div>There are {userCount} other user(s) online</div>;
}

以下をターミナルで試します。

npm run dev

以下画像のように、同じURLを仕様している人がいればいるほど、数字が増えていきます。
これで、同じURL上でリンクしているのが実感できますね。

Collaborative WhiteboardをApp routerに合わせる

liveblocksではさまざまなサンプルを出しています。参考にしてください。
https://liveblocks.io/examples

今回は、学習も兼ねてCollaborative WhiteboardをApp routerの形に合わせていきたいと思います。
https://liveblocks.io/examples/collaborative-whiteboard

ファイル構成

/my-nextjs-app
├── /app
│   ├── layout.tsx          # `LiveblocksProvider` をアプリ全体で適用
│   ├── page.tsx            # ルートページで `Room` を表示 
│   ├── globals.css         # アプリ全体のグローバル CSS
│   ├── Room.tsx            # RoomProvider` を設定し `Canvas.tsx` を含む
├── /components
│   ├── Canvas.tsx          # 共同編集可能なキャンバスの UI とロジック
├── /styles
│   ├── index.module.css    # `Canvas` のデザイン
├── liveblocks.config.ts    # Liveblocks の API 設定ファイル
├── .gitignore
├── next.config.js
├── package.json
├── tsconfig.json
app/layout.tsx
"use client";

import { ReactNode } from "react";
import { LiveblocksProvider } from "@liveblocks/react";
import "./globals.css";

export default function Layout({ children }: { children: ReactNode }) {
  return (
    <html lang="en">
      <body>
        <LiveblocksProvider
          publicApiKey={process.env.NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY!}
        >
          {children}
        </LiveblocksProvider>
      </body>
    </html>
  );
}
  • LiveblocksProviderを最上位レベルに配置することで、全ページで Liveblocks の機能を使用可能。
app/page.tsx
import Room from "./Room";

export default function Page() {
  return <Room />;
}

Roomの説明

役割

  • RoomProviderを使用してLiveblocksのルームを作成。
  • Canvas.tsxを内部でレンダリングし、リアルタイム共同編集の環境を提供。
app/Room.tsx
"use client";

import { RoomProvider, ClientSideSuspense } from "@liveblocks/react/suspense";
import { LiveMap } from "@liveblocks/client";
import styles from "../styles/index.module.css";
import Canvas from "./Canvas";

export default function Room() {
  return (
    <RoomProvider                                                                                                 # リアルタイム同期の部屋 を作成
      id="nextjs-whiteboard"
      initialPresence={{ selectedShape: null }}                                                                   # ユーザーの選択情報を初期化
      initialStorage={{ shapes: new LiveMap() }}                                                                  # 図形のストレージを作成
    >
      <div className={styles.container}>
        <ClientSideSuspense fallback={<div>Loading...</div>}>                                                     # Canvas.tsx を非同期処理(ロード時の待機)
          <Canvas />
        </ClientSideSuspense>
      </div>
    </RoomProvider>
  );
}

ポイトンとしては、

  • RoomProvider → ルームを管理し、ユーザーのリアルタイム更新を提供
  • ClientSideSuspense → クライアント側で非同期処理をハンドリング

Canvas.tsxの説明

役割:

  • Liveblocksを利用してリアルタイムの図形(矩形)の追加・削除・移動を管理。
  • useStorage()を使用して サーバーと同期しながら図形を管理。
  • ユーザー間でのリアルタイムな変更を反映。
app/Canvas.tsx
"use client";

import { useState, PointerEvent } from "react";                                                                   # すべての図形のデータを取得(サーバーと同期)
import { useStorage, useMutation, useSelf, useOthers, useHistory } from "@liveblocks/react/suspense";             # useMutation → 図形の追加・削除・移動を実行。useHistory → Undo / Redo の履歴管理
import { LiveObject } from "@liveblocks/client";
import styles from "../styles/index.module.css";

export default function Canvas() {
  const [isDragging, setIsDragging] = useState(false);
  const history = useHistory();
  const shapeIds = useStorage((root) => Array.from(root.shapes.keys()));

  const insertRectangle = useMutation(({ storage, setMyPresence }) => {                                           # 図形の追加
    const shapeId = Date.now().toString();                                                                        # 図形ごとに異なるIDを作成
    const shape = new LiveObject({ x: getRandomInt(300), y: getRandomInt(300), fill: getRandomColor() });         # 図形のデータを作る(x座標, y座標, 色)
    storage.get("shapes").set(shapeId, shape);                                                                    # 保存
    setMyPresence({ selectedShape: shapeId }, { addToHistory: true });                                            # 「この図形を選択したよ」と記録
  }, []);

  const deleteRectangle = useMutation(({ storage, self, setMyPresence }) => {                                     # 図形の削除
    const shapeId = self.presence.selectedShape;                                                                  # 現在選択中の図形を取得
    if (!shapeId) return;
    storage.get("shapes").delete(shapeId);                                                                        # 削除
    setMyPresence({ selectedShape: null });                                                                       # 選択をリセット
  }, []);

  const onShapePointerDown = useMutation(                                                                         # 図形をクリックすると選択する
    ({ setMyPresence }, e: PointerEvent<HTMLDivElement>, shapeId: string) => {
      history.pause();
      e.stopPropagation();
      setMyPresence({ selectedShape: shapeId }, { addToHistory: true });
      setIsDragging(true);
    },
    [history]
  );

  const onCanvasPointerUp = useMutation(                                                                          # クリックを離すと選択解除                                                                       
    ({ setMyPresence }) => {
      if (!isDragging) {
        setMyPresence({ selectedShape: null }, { addToHistory: true });
      }
      setIsDragging(false);
      history.resume();
    },
    [isDragging, history]
  );

  const onCanvasPointerMove = useMutation(                                                                         # 図形をドラッグで移動
    ({ storage, self }, e: PointerEvent<HTMLDivElement>) => {
      e.preventDefault();
      if (!isDragging) return;                                                                                     # ドラッグ中かチェック

      const shapeId = self.presence.selectedShape;                                                                 # 選択中の図形を取得
      if (!shapeId) return;

      const shape = storage.get("shapes").get(shapeId);
      if (shape) {
        shape.update({ x: e.clientX - 50, y: e.clientY - 50 });                                                    # マウスの座標に合わせて図形を移動
      }
    },
    [isDragging]
  );

  return (
    <>
      <div className={styles.canvas} onPointerMove={onCanvasPointerMove} onPointerUp={onCanvasPointerUp}>          # キャンバスを作成
        {shapeIds.map((shapeId: string) => (
          <Rectangle key={shapeId} id={shapeId} onShapePointerDown={onShapePointerDown} />
        ))}
      </div>
      <div className={styles.toolbar}>                                                                             # 図形の追加・削除・Undo・Redo の機能を提供
        <button onClick={insertRectangle}>Rectangle</button>
        <button onClick={deleteRectangle}>Delete</button>
        <button onClick={history.undo}>Undo</button>
        <button onClick={history.redo}>Redo</button>
      </div>
    </>
  );
}

type RectangleProps = {                                                                                            # Rectangle コンポーネントの型定義
  id: string;
  onShapePointerDown: (e: PointerEvent<HTMLDivElement>, id: string) => void;
};

function Rectangle{ id, onShapePointerDown }: RectangleProps) {                                                    # 図形を表示する部分
  const { x, y, fill } = useStorage((root) => root.shapes.get(id)) ?? {};
  const selectedByMe = useSelf((me) => me.presence.selectedShape === id);
  const selectedByOthers = useOthers((others) => others.some((other) => other.presence.selectedShape === id));
  const selectionColor = selectedByMe ? "blue" : selectedByOthers ? "green" : "transparent";

  return (
    <div
      onPointerDown={(e) => onShapePointerDown(e, id)}
      className={styles.rectangle}
      style={{
        transform: `translate(${x}px, ${y}px)`,
        transition: !selectedByMe ? "transform 120ms linear" : "none",
        backgroundColor: fill || "#CCC",
        borderColor: selectionColor,
      }}
    />
  );
}

const COLORS = ["#DC2626", "#D97706", "#059669", "#7C3AED", "#DB2777"];

function getRandomInt(max: number): number {
  return Math.floor(Math.random() * max);
}

function getRandomColor(): string {
  return COLORS[getRandomInt(COLORS.length)];
}

index.module.cssの説明

役割:

  • Canvas.tsxRoom.tsxに適用するスタイルを定義。
styles/index.module.css
.container {                                                                                    # 画面からはみ出さないようにする
    margin: 0;
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
      "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
      sans-serif;
    overflow: hidden;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
    background-color: #eeeeee;
  }
  
  .canvas {                                                                                     # 画面いっぱいのキャンバスを作る
    touch-action: none;
    width: 100vw;
    height: 100vh;
  }
  
  .rectangle {                                                                                  # 位置を自由に動かす
    position: absolute;
    stroke-width: 1px;
    border-style: solid;
    border-width: 2px;
    height: 100px;
    width: 100px;
  }
  
  .toolbar {                                                                                    # 以下、ツールバーの設定
    position: fixed;
    top: 12px;
    left: 50%;
    transform: translateX(-50%);
    padding: 4px;
    border-radius: 8px;
    box-shadow:
      0px 2px 4px rgba(0, 0, 0, 0.1),
      0px 0px 0px 1px rgba(0, 0, 0, 0.05);
    display: flex;
    background-color: #ffffff;
    user-select: none;
  }
  
  .toolbar button {
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 4px 8px;
    border-radius: 4px;
    background-color: #f8f8f8;
    color: #181818;
    border: none;
    box-shadow:
      0px 2px 4px rgba(0, 0, 0, 0.1),
      0px 0px 0px 1px rgba(0, 0, 0, 0.05);
    margin: 4px;
    font-weight: 500;
    font-size: 12px;
  }
  
  .toolbar button:not(:disabled):hover,
  .toolbar button:not(:disabled):focus {
    background-color: #ffffff;
  }
  
  .toolbar button:not(:disabled):active {
    background-color: #eeeeee;
  }
  
  .toolbar button:disabled {
    opacity: 0.5;
  }
  
  .loading {
    position: absolute;
    width: 100vw;
    height: 100vh;
    display: flex;
    place-content: center;
    place-items: center;
  }
  
  .loading img {
    width: 64px;
    height: 64px;
    opacity: 0.2;
  }

ポイント

  • .canvas → キャンバス全体のスタイル
  • .rectangle → 矩形のスタイル
  • .toolbar → ボタン UI のスタイル

liveblocks.config.tsの修正

liveblocks.config.ts
import { LiveMap, LiveObject } from "@liveblocks/client";

+ type Shape = LiveObject<{
+   x: number;
+   y: number;
+   fill: string;
+ }>;

// Define Liveblocks types for your application
// https://liveblocks.io/docs/api-reference/liveblocks-react#Typing-your-data
declare global {
  interface Liveblocks {
    // Each user's Presence, for useMyPresence, useOthers, etc.
    Presence: {
+      selectedShape: string | null;
    };

    // The Storage tree for the room, for useMutation, useStorage, etc.
    Storage: {
+      shapes: LiveMap<string, Shape>;
    };

    // Custom user info set when authenticating with a secret key
    UserMeta: {
      id: string;
      info: {
        // Example properties, for useSelf, useUser, useOthers, etc.
        // name: string;
        // avatar: string;
      };
    };

    // Custom events, for useBroadcastEvent, useEventListener
    RoomEvent: {};
      // Example has two events, using a union
      // | { type: "PLAY" } 
      // | { type: "REACTION"; emoji: "🔥" };

    // Custom metadata set on threads, for useThreads, useCreateThread, etc.
    ThreadMetadata: {
      // Example, attaching coordinates to a thread
      // x: number;
      // y: number;
    };

    // Custom room info set with resolveRoomsInfo, for useRoomInfo
    RoomInfo: {
      // Example, rooms with a title and url
      // title: string;
      // url: string;
    };
  }
}

export {};

描画

Canvasで図形を共同表示できるWebアプリが表示できたのではないでしょうか?

最後に

本当は、リアクションボタンも搭載したので技術の棚卸しとして記事にしたかったですが、次回にしようと思っています。
よろしくお願いいたします。

Discussion