はじめに
Google Docs のように「複数人が同時に同じ画面を編集できる」リアルタイム共同編集は、最近のWebアプリでは半ば当たり前の機能になりました。しかし、いざ自分で実装しようとすると次のような壁にぶつかります。
- 競合解決をどうするか? 同じノードを2人が同時に編集したら、どちらの変更が勝つのか?
- WebSocket をどこで動かすか? Vercel のようなサーバーレス環境は長寿命接続に向かない
- データはどこに保存するか? リアルタイム差分・スナップショット・ローカル下書きで保存先が変わる
本記事では、私が個人開発した MindSource というマインドマップアプリを題材に、Yjs(CRDT) と サーバーレス + 常駐WSハイブリッド構成 でこれらの課題をどう解決したかを解説します。
GitHub: https://github.com/KuwadaKouhei/MindSource
対象読者
- リアルタイム共同編集機能を作ってみたいが、設計の落としどころが分からない方
- Yjs / CRDT という言葉は聞いたことがあるが、実際にどう使うか知らない方
- Vercel / Next.js 環境で WebSocket をどう扱うか悩んでいる方
この記事で分かること
- なぜリアルタイム共同編集に Yjs(CRDT) が向いているのか
- Vercel と Render を使い分けるハイブリッド構成の組み方
- ローカル下書き・権威データ・リアルタイム差分の 三層永続化設計
- collab-server を 約60行で立ち上げる最小実装
動作環境
| 項目 | バージョン |
|---|---|
| Next.js | 16 |
| React | 19 |
| TypeScript | 5 |
| Yjs | 13系 |
| y-websocket | 3系 |
| Supabase | Auth + Postgres + RLS |
TL;DR
リアルタイム共同編集を実現するために、MindSource では以下の3点を組み合わせました。
- Yjs (CRDT) でサーバー側の競合解決ロジックをゼロに。サーバーは「差分を受け取って配るだけ」の薄い層で済む
- Vercel(サーバーレス) + Render(常駐WS)のハイブリッド構成。WebSocketが必要な部分だけ常駐に切り出す
- IndexedDB + Supabase + y-leveldb の三層永続化。「ログイン前下書き」「権威データ」「リアルタイム差分」を別々のストアで扱う
それぞれの設計判断と実装の核を順番に説明していきます。
完成イメージ
GitHubのREADMEに動作GIFを置いています。2つのブラウザを並べて開くと、片方の編集がもう片方に即座に反映されます。
ライブデモも公開しています(ログインなしでも試せます)。
1. なぜ Yjs(CRDT)を選んだのか
CRDT とは何か
CRDT(Conflict-free Replicated Data Type)は、複数のレプリカで同じデータを編集しても、最終的に必ず同じ結果に収束することが数学的に保証されたデータ構造です。日本語に直すと「競合解決不要なデータ型」という意味になります。
ざっくり言うと、CRDT に乗せたデータは次のような性質を持ちます。
- 編集はすべて「操作の集合」として扱われる
- 操作の到着順が違っても、最終結果は同じになる
- ネットワークが切れていても、ローカルで編集できる
- 後で繋がったときに、自動でマージされる
これは、リアルタイム共同編集の「同じノードを2人が同時に編集したら?」という問題を、サーバーで判定するのではなくデータ構造そのもので解決するアプローチです。
CRDT の代表的な実装が Yjs です。Notion・Figma・Linear など多くのプロダクトが似た系統の技術(OT または CRDT)を採用しています。
サーバー側の競合解決を書きたくなかった
もし CRDT を使わない場合、サーバー側で次のようなロジックを書く必要があります。
- 「Aさんがノード1を編集」「Bさんがノード1を同時に編集」が同時に来たらどう統合するか
- タイムスタンプはどう扱うか(ネットワーク遅延で順序が逆転する問題)
- ロック機構を入れるか、Operational Transformation を実装するか
これは想像するだけで気が重くなる作業です。Yjs を使えば、**サーバーは「更新を受け取って他のクライアントに配るだけ」**の薄い層に保てます。
Y.Doc を「真の編集対象」にする発想
Yjs を使う上で重要な発想の転換があります。それは「UI の state ではなく、Y.Doc が真の編集対象」というものです。
通常の React アプリでは、useState などで管理する state が編集対象です。しかし Yjs を使う場合は、Y.Doc が状態の真実の所在になり、UI はそのスナップショットを表示しているだけ、という構造に変わります。
このパラダイムシフトが受け入れられれば、あとは Yjs のお作法に従うだけで共同編集が動き出します。
データ構造の設計:3つの Y.Map に分離
MindSource では、マインドマップのデータを 3つの Y.Map に分けて持っています。
// 構造を3つのY.Mapに分離
const yNodes = ydoc.getMap<YNodeValue>("nodes");
const yEdges = ydoc.getMap<YEdgeValue>("edges");
const yMeta = ydoc.getMap<YMetaValue>("meta");
なぜ1つの Y.Map にまとめないのか? 理由は 更新の局所性です。
- ノードのテキストを編集しただけなのに、エッジの情報まで送られるのは無駄
- メタ情報(マップタイトル等)の変更で、全ノードに更新通知が飛ぶのも無駄
Y.Map ごとにオブザーバを分けられるので、「ノードの変更だけを購読する」「メタ情報の変更だけを購読する」という細かい制御ができるようになります。
Y.UndoManager で「ローカル編集だけ」を Undo 対象にする
共同編集アプリで地味に厄介なのが Undo です。
Aさんが編集 → Bさんが編集 → Aさんが Ctrl+Z
このとき、もし「全員の編集履歴」を Undo 対象にしてしまうと、A さんの Ctrl+Z で B さんの編集が消える事故が起きます。これは絶対に避けたい挙動です。
Yjs には Y.UndoManager という仕組みがあり、特定の origin(編集元)だけを Undo 対象にすることができます。
const undoManager = new Y.UndoManager(
[yNodes, yEdges],
{
trackedOrigins: new Set([LOCAL_ORIGIN]),
}
);
trackedOrigins に「ローカル編集を表す origin」を指定することで、自分の編集だけが Undo 対象になります。他人の編集は Undo スタックに積まれないので、誤って巻き戻すことがありません。
Yjs の transact(callback, origin) で編集に origin を付けられます。リモートからの更新には別の origin が付くので、それで自/他を区別する仕組みです。
2. なぜ「サーバーレス + 常駐 WS」のハイブリッドなのか
Vercel が WebSocket に向かない理由
MindSource のフロントエンドは Next.js 製で、Vercel にデプロイしています。Vercel は素晴らしいプラットフォームですが、WebSocket の長寿命接続には向きません。
理由はざっくり以下の通りです。
- Vercel の Functions は サーバーレスで、リクエスト単位で起動・終了する
- WebSocket は接続が確立したら何時間も維持されるのが前提
- サーバーレス関数の最大実行時間(Hobbyプランで10秒、Proで60秒など)にすぐ引っかかる
Edge Functions を使えば多少改善しますが、それでも常駐プロセスとしての安定運用には向いていません。
解決策:collab-server だけを Render に切り出す
そこで MindSource では、WebSocket が必要な部分だけ別サーバー(Render)に切り出すハイブリッド構成にしました。
役割分担はこうなります。
| 担当 | プラットフォーム | 何をする |
|---|---|---|
| フロントエンド配信 | Vercel(サーバーレス) | SSR・静的ファイル配信 |
| 認証・CRUD API | Vercel(サーバーレス) | Supabase との橋渡し |
| 共同編集 WebSocket | Render(常駐) | Y.Doc の差分配信 |
| AI 関連語生成 | 別サービス | キー漏洩防止のため Vercel 側でプロキシ |
ポイントは、常駐プロセスが必要な部分にだけ常駐コストを払うという割り切りです。Render のフリープランでも一旦動かせるので、個人開発ならコストは抑えられます。
Render のフリープランはアイドル時にスリープします。本番運用では有料プランに切り替えるか、定期的にヘルスチェックでpingする等の工夫が必要です。
collab-server の最小実装(約60行)
ここがこの記事で一番伝えたい部分です。collab-server の中身は驚くほどシンプルです。
import http from "node:http";
import { WebSocketServer } from "ws";
// @ts-expect-error - y-websocket ships the bin handler without types
import { setupWSConnection, setPersistence } from "y-websocket/bin/utils";
// @ts-expect-error - y-leveldb has no type declarations
import { LeveldbPersistence } from "y-leveldb";
const PORT = Number(process.env.PORT ?? 1234);
const HOST = process.env.HOST ?? "0.0.0.0";
const DATA_DIR = process.env.DATA_DIR ?? "./data";
const ALLOWED_ORIGINS = (process.env.CLIENT_ORIGIN ?? "http://localhost:3000")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
// 1) 永続化の設定
const persistence = new LeveldbPersistence(DATA_DIR);
setPersistence({
bindState: async (docName, ydoc) => {
const persisted = await persistence.getYDoc(docName);
const current = (await import("yjs")).encodeStateAsUpdate(ydoc);
await persistence.storeUpdate(docName, current);
(await import("yjs")).applyUpdate(
ydoc,
(await import("yjs")).encodeStateAsUpdate(persisted)
);
ydoc.on("update", (update) => {
persistence.storeUpdate(docName, update);
});
},
writeState: async () => {
// y-leveldb は自動 flush
},
});
// 2) HTTP サーバ(ヘルスチェック用)
const server = http.createServer((_req, res) => {
res.writeHead(200, { "content-type": "text/plain" });
res.end("mindsource collab-server ok\n");
});
// 3) WebSocket サーバ
const wss = new WebSocketServer({ noServer: true });
wss.on("connection", (conn, req) => {
// URL のパスから docName(部屋名)を取り出す
const url = req.url ?? "/";
const docName = url.replace(/^\/(ws\/)?/, "").split("?")[0] || "default";
setupWSConnection(conn, req, { docName, gc: true });
});
// 4) Origin チェック付きの upgrade ハンドリング
server.on("upgrade", (req, socket, head) => {
const origin = req.headers.origin;
if (origin && !ALLOWED_ORIGINS.includes(origin)) {
socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (ws) =>
wss.emit("connection", ws, req)
);
});
server.listen(PORT, HOST);
このコードがやっていることは4つだけです。
- 永続化:受け取った差分を y-leveldb に書き込む
- HTTP サーバ:ヘルスチェック用のエンドポイント
-
WebSocket 接続処理:URL のパスを部屋名(
docName)として扱う - Origin チェック:許可していないオリジンからの接続を 403 で拒否
差分のマージや配信は y-websocket パッケージの setupWSConnection がすべて面倒を見てくれるので、こちら側で書くロジックはほぼゼロです。
docName を URL のパスから取るだけで、自動的に「部屋」が分かれます。/map-abc と /map-xyz にアクセスすれば別の Y.Doc として扱われます。
3. 三層永続化:IndexedDB + Supabase + y-leveldb
3つの保存先を使い分ける理由
「データを保存する」と一口に言っても、リアルタイム共同編集では3つの異なる保存ニーズが発生します。
| 保存先 | 何を保存するか | なぜここか |
|---|---|---|
| IndexedDB(ブラウザ) | ログイン前の下書き | サーバーに送る前の試作 |
| Supabase Postgres | 権威スナップショット | 正式な「マップの最新状態」 |
| y-leveldb(Render) | リアルタイム差分 | WS再接続時の追いつき |
これらを混同して1つに統合してしまうと、それぞれの責務が崩れます。たとえば「ログイン前の下書き」を Supabase に書こうとすると認証の問題が出ますし、「リアルタイム差分」を Postgres に書くと書き込みコストで死にます。
各層の役割
① IndexedDB:ログイン前の下書き
「とりあえず触ってみたい」というユーザーがログインなしで使えるよう、ローカルにドラフトを持ちます。MindSource では idb-keyval を使った薄いラッパー経由で保存しています。
ログイン後にこのドラフトをサーバーにインポートできるフローを用意することで、「試す → 気に入った → 保存する」の動線を切らずに済みます。
② Supabase Postgres:権威スナップショット
「最新のマップはどんな状態か」を表す権威データは Supabase に保存します。一定間隔または明示保存のタイミングで、Y.Doc の現在状態をスナップショットとして書き込みます。
権限管理は Supabase の RLS(Row Level Security) に任せています。これがめちゃくちゃ便利で、「このマップを見られるか / 編集できるか」の判定を Postgres のポリシー定義として書けます。
-- 例: 自分が所有 or 共同編集者として登録されている場合のみ閲覧可能
CREATE POLICY "view_own_maps" ON mindmaps
FOR SELECT
USING (
owner_id = auth.uid()
OR EXISTS (
SELECT 1 FROM map_collaborators
WHERE map_id = mindmaps.id AND user_id = auth.uid()
)
);
Next.js の API Route はユーザーの cookie を Supabase クライアントに渡すだけで、認可ロジックを Postgres 側が処理してくれます。アプリケーションコードに権限判定を散らばらせなくて済むのが大きな利点です。
③ y-leveldb:リアルタイム差分
collab-server 側で、Y.Doc に来た更新を逐次 y-leveldb に書き込んでいます。これは何のためかというと、WebSocket が一時的に切断したクライアントが再接続した際に、不足分の差分を渡すためです。
Y.Doc は差分の集合なので、「どこから始まる差分が欲しいか」を指定すれば、クライアント側で自動的に追いつきます。これは Yjs の特性上、サーバーで何も追加実装しなくても勝手に動きます。
データの流れまとめ
役割が綺麗に分離されているので、それぞれの層を独立に進化させられるのが利点です。
4. セキュリティで気をつけた2点
Origin チェックで WS を保護
WebSocket は HTTP のような CORS の仕組みが効きにくいため、意図しないオリジンからの接続を弾く処理を自前で書く必要があります。
server.on("upgrade", (req, socket, head) => {
const origin = req.headers.origin;
if (origin && !ALLOWED_ORIGINS.includes(origin)) {
socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (ws) =>
wss.emit("connection", ws, req)
);
});
許可するオリジンは環境変数 CLIENT_ORIGIN で設定して、本番とプレビューデプロイで切り替えています。
これだけでは「許可されたオリジンから来たユーザーが、自分が見られないはずのマップを覗く」のは防げません。最終的な権限チェックは Supabase RLS が担当しています。WS層は「外部からの無差別接続を絞る」レイヤだと割り切っています。
AI API キーを Next.js API Route でプロキシ化
MindSource は関連語生成のために自作の AI API(relation-word-api)を呼んでいます。このAPIにはキーが必要で、キーをブラウザに露出させるとあっという間に悪用されます。
そこで、Next.js の API Route を必ず経由させる構成にしました。
export async function POST(req: Request) {
// 1) ブラウザは APIキーを持たない
const { word } = await req.json();
// 2) サーバー側だけが APIキーを知っている
const apiKey = process.env.RELATION_WORD_API_KEY;
const baseUrl = process.env.RELATION_WORD_API_BASE_URL;
// 3) サーバー → AI API への呼び出し
const res = await fetch(`${baseUrl}/related`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-API-Key": apiKey!,
},
body: JSON.stringify({ word }),
});
return Response.json(await res.json());
}
process.env.RELATION_WORD_API_KEY は NEXT_PUBLIC_ プレフィックスを付けないことが重要です。これがないと Next.js のビルド時にバンドルに含まれず、サーバー側でのみアクセス可能になります。
5. 現状の限界と未着手の論点
正直に書きますが、MindSource にはまだ未解決の課題があります。同じような構成で何かを作ろうとしている方の参考になれば、ということで明記しておきます。
- 同時編集ユーザー上限は10名程度:それ以上は awareness(カーソル位置などの同期)の帯域と React Flow の再レンダリングコストで体感が悪化する。シャーディングは未検討
-
collab-server の水平スケール未対応:y-leveldb がファイルシステム依存のため、複数インスタンスで同じ部屋を扱えない。Redis版(
y-redis)への差し替えが必要 - オフライン編集の自動マージは部分的:Yjs 的にはマージされるが、UX として「オフライン中の操作が後で戻ってくる感」の作り込みは未完
- AI レスポンスのストリーミング未対応:関連語は一括返却。SSE 化は将来課題
逆に言うと、この4点が許容できるユースケースなら、MindSource と同じ構成で十分実用になるということです。
まとめ
リアルタイム共同編集を作るうえで効いた設計判断は以下の3つでした。
- Yjs(CRDT)でサーバー側の競合解決ロジックをゼロに。collab-server は約60行で済む薄い層になる
- Vercel + Render のハイブリッド構成。サーバーレスで困る部分(WS長寿命接続)だけ常駐に切り出す
- 三層永続化。「ログイン前下書き」「権威データ」「リアルタイム差分」を別々のストアで持つことで、各層が独立に進化できる
特に2点目は、Vercel ユーザーがリアルタイム機能を諦めがちな部分です。**「全部 Vercel で完結させる」ではなく「向いている部分だけ Vercel」**と割り切れば、選択肢は広がります。
同じ構成を再現するなら、以下のチェックリストを参考にしてください。
-
フロントは Next.js + Yjs(クライアント側で Y.Doc を作って
y-websocketで繋ぐ) - WS サーバは Render などの常駐環境に切り出す(コードは約60行)
- 認証・権限管理は Supabase RLS に寄せる
- ログイン前体験のために IndexedDB 下書きを用意する
- 外部 API キーは必ず BFF(Next.js API Route)を経由
実装の全体像は GitHub で公開しているので、興味があれば覗いてみてください。
質問・指摘はこの記事のコメントか GitHub の Issue でお気軽にどうぞ。
参考資料