Notionにモヤっとして作った、無制限のコラボツール 🚀
Google Docsは便利だけど、UIがちょっと味気ない。
Notionは見た目も雰囲気も好きだけど、チームで本格的に使おうとすると有料プランの負担が気になる…。
さらに、共同作業をしているときにFigmaのように
「誰がどこを見ているのか」がリアルタイムのカーソルで見えたら、
もっと“みんなで一緒に作っている”感覚が出るんじゃないか?
そんな不便さから生まれた個人プロジェクトが TeamSpace です。
メンバーを招待した途端に重くなるワークスペース、
ブロック制限に悩まされる日々とは、もうお別れです! 👋
TeamSpaceは、あなたの成長を邪魔しません。
✅ 2人以上で使ってもブロック数は無制限!
✅ 有料プランのプレッシャーなし!
✅ Notionよりも軽く、快適なコラボレーション!
✅ Figmaのようなリアルタイムカーソルで、一緒に作業している感覚を実現!
✅ 心地よいUIと実用的なドキュメント共同編集を両立!
専門的な組織でなくても大丈夫です。
大学のグループ課題、サイドプロジェクト、
就職活動に向けたポートフォリオ整理や、
勉強会・スタディ資料の共有にもぴったりです。
就活中や学生の頃は、たとえ小さな月額料金でも負担になります。
きちんとした共同作業機能を使うために、
毎月課金しなければならないことに負担を感じていた方は、
ぜひ TeamSpaceを無料で始めてみてください。
ストレスのないチームワークを体験できます。
Next.js + Liveblocks + BlockNoteでリアルタイム共同編集ドキュメントアプリを作った話
作った理由
個人プロジェクトとして、TeamSpace というリアルタイム共同編集ドキュメントプラットフォームを作りました。
最初から単なるメモアプリを作りたかったわけではありません。Notionのようにページを階層構造で管理しつつ、複数人が同じドキュメントを同時に編集でき、さらにワークスペース単位で権限管理もできるような仕組みを自分で実装してみたいと思ったのがきっかけです。
特に意識したのは以下の点です。
- ワークスペース単位のメンバー管理と権限管理
- 親子関係を持つドキュメントツリー
- リアルタイム共同編集とマルチカーソル
- 公開リンクによる読み取り専用共有
- 招待、承認、拒否のフロー
- スナップショットによる過去バージョンの復元
- 共同編集エディタの初期表示パフォーマンス改善
単に機能を並べるのではなく、ユーザーがドキュメントを移動し、編集し、共有する一連の流れが自然に感じられることを目標にしました。
技術スタック
主な技術スタックは以下の通りです。
- Frontend: Next.js 16 App Router, React 19, TypeScript
- UI: Tailwind CSS v4, shadcn/ui
- State/Data: TanStack Query, Zustand
- Auth: NextAuth v5 beta, Prisma Adapter
- DB/ORM: PostgreSQL, Prisma
- Realtime: Liveblocks, BlockNote
- Validation: Zod, React Hook Form
Next.js App Routerをベースに、認証済みユーザー向けの画面、公開共有ページ、API Route Handlerを分けて実装しました。
認証、ワークスペース、ページ作成、招待、公開共有などの処理はサーバー側のクエリとして分離し、UI側から直接データ構造に依存しすぎないようにしました。
主な実装
1. ワークスペース単位の権限管理
TeamSpaceでは、個人用ワークスペースとチーム用ワークスペースを分けています。
各ワークスペースにはメンバーが所属し、それぞれ以下のようなロールを持ちます。
- OWNER
- ADMIN
- MEMBER
- VIEWER
このロールは、ドキュメントへのアクセス可否、編集権限、招待操作などに利用しています。
リアルタイムエディタでも、権限に応じてアクセスレベルを分けました。
- VIEWER: 読み取り専用
- MEMBER以上: 編集可能
- 未ログインユーザー: 公開ドキュメントのみ読み取り可能
この設計により、ログインユーザー、チームメンバー、外部共有ユーザーを同じアクセスモデルの中で扱えるようにしました。
2. ドキュメントツリー構造
ドキュメントは parentId を使って階層構造を表現しています。
ルートページと子ページを作成でき、サイドバーではツリー形式で表示します。
各ドキュメントでは、主に以下の情報を管理しています。
- タイトル
- アイコン
- 親ドキュメントID
- 公開状態
- 共有トークン
- スナップショットデータ
この構造により、ユーザーはワークスペース内のドキュメントをフォルダのように移動でき、必要なページだけを公開リンクで共有できます。
3. Liveblocks + BlockNoteによるリアルタイム共同編集
リアルタイム編集は Liveblocks と BlockNote を組み合わせて実装しました。
各ドキュメントは pageId を基準に、1つの Liveblocks Room に紐づけています。
つまり、ユーザーが別のページに移動すると、単なる画面遷移ではなく、そのページに対応する共同編集 Room に入り直すことになります。
ページ遷移時には、以下の処理が同時に発生します。
- Liveblocks Roomへの接続
- ドキュメントstorageの同期
- BlockNote editorの初期化
- presenceの同期
- マルチカーソルの表示
最初はエディタを組み込めば終わりだと思っていましたが、実際には共同編集状態とUI状態を「いつ接続し、いつ切り離すか」がかなり重要でした。
詰まった問題
1. ページ切り替え時の初期表示が遅い
共同編集エディタを実装したあと、ドキュメントをクリックしても本文がすぐに表示されない問題がありました。
最初は以下のような原因を疑いました。
- roomIdが二重に初期化されている
- BlockNote editorインスタンスが不要に再生成されている
- React Queryのrefetchが影響している
しかしログを確認したところ、roomIdの二重初期化やeditorの再生成が主な原因ではありませんでした。
実際のボトルネックは CursorLayer でした。
CursorLayerは他ユーザーのカーソルを表示するため、エディタ領域のlayout情報を計算していました。ページ初期表示時には以下の処理が集中します。
- room接続
- storage sync
- editor初期化
- presence同期
- layout計算
その結果、初期表示に約2.2秒ほどかかっていました。
2. Tree UIのflickering
ページを切り替えるたびに、サイドバーのTree UIが一瞬空になってから再描画される問題もありました。
最初は React Query の refetch や条件付きレンダリングが原因だと考えました。
そこで staleTime を伸ばしたり、データがまだない場合でもコンポーネント自体は消さないようにしたりしました。
しかし根本原因は別にありました。
現在のページまでのパスである ancestorPath を props として渡していたのですが、ページをクリックするたびに新しい配列インスタンスが生成されていました。
Reactでは配列の参照が変わると別の値として扱われるため、Tree全体が不要に再レンダリングされていました。
3. window is not defined
Next.js App RouterでBlockNoteを使った際に、window is not defined エラーにも遭遇しました。
最初は 'use client' を付ければ、ブラウザ専用のコードはサーバー側で実行されないと思っていました。
しかし、BlockNoteのようにブラウザAPIへの依存が強いライブラリでは、import時や初期化時に window や document にアクセスする場合があります。
つまり、'use client' だけではSSRとの衝突を完全には避けられませんでした。
4. リアルタイムカーソルの位置ズレ
マルチカーソルを実装しているとき、同じ場所を指しているはずなのに、ユーザーごとにカーソル位置がズレる問題がありました。
原因は座標系でした。
最初は viewport 基準の座標を使っていましたが、エディタは中央寄せのレイアウトだったため、ブラウザ幅が変わると左右の margin も変わります。
その結果、同じ座標を送信しても、ユーザーごとにエディタ内部での実際の位置が変わってしまいました。
解決方法
1. CursorLayerの有効化タイミングを制御する
最初は CursorLayer を3秒後に有効化することで問題を緩和しました。
しかし、ネットワーク環境によって Liveblocks Room の接続時間や editor の初期化時間は変わります。そのため、固定時間で遅延させる方法は安定した解決策ではありませんでした。
最終的には、以下の条件を両方満たしたときだけ CursorLayer を有効化するようにしました。
- editorの準備が完了している
- layout計算が完了している
また、CursorLayerの有効/無効状態は Zustand のグローバル状態で管理しました。
ページ移動時にはまず CursorLayer を無効化し、新しいページで準備が完了してから再度有効化します。
この変更により、初期表示時間を約 2200ms から 334ms まで改善できました。
2. Treeのopen状態をオブジェクトで管理する
Tree UIのflickeringは、ancestorPath を毎回 props として渡す構造を見直すことで解決しました。
以前は、現在のページの ancestorPath に含まれるノードだけを開くようにしていました。そのため、別のページをクリックすると、既に開いていたノードが閉じ、新しいパスに合わせて再度開き直されていました。
しかしユーザー視点では、一度開いたノードはページ移動後も維持されているほうが自然です。
そこで、Treeのopen状態をオブジェクトで管理するようにしました。
type OpenMap = Record<string, boolean>;
変更後は以下のように動作します。
- 初回表示時のみ ancestorPath をもとに必要なノードを開く
- ユーザーが手動で開いたノードは状態として保持する
- ページ移動時も既存のopen状態を維持する
- 初期化ロジックとユーザー操作による状態変更を分離する
この変更により、Tree UIのflickeringがなくなり、open/closeの挙動も安定しました。
3. BlockNoteをdynamic importで分離する
window is not defined の問題は、エディタコンポーネントをSSRの経路から完全に外すことで解決しました。
import dynamic from 'next/dynamic';
const Editor = dynamic(() => import('../../Editor').then((m) => m.Editor), {
ssr: false,
});
ssr: false を指定すると、そのコンポーネントはサーバーサイドレンダリングの対象外になります。
BlockNoteのようにブラウザ環境を前提とするライブラリでは、単に 'use client' を付けるだけでなく、必要に応じて dynamic import で明確に分離するほうが安全でした。
4. カーソル座標をエディタ基準に変更する
カーソル位置のズレは、座標の基準を viewport から editor rect に変更することで解決しました。
具体的には以下のようにしました。
-
getBoundingClientRect()でエディタ領域を取得 -
ResizeObserverでサイズ変更を検知 -
scroll/resizeイベントで rect を更新 - マウス座標をエディタ内部の相対座標に変換
- 他ユーザー側でもエディタ基準の座標として復元
また、rect の値を React state で管理すると再レンダリングが増えたため、カーソル位置の表示には CSS Custom Property と transform: translate3d(...) を利用しました。
これにより React の再レンダリングを減らし、カーソル移動もより滑らかになりました。
パフォーマンス・セキュリティ・UXで学んだこと
パフォーマンス
リアルタイム共同編集では、単純なレンダリング速度よりも初期化タイミングの設計が重要でした。
ページ移動時には以下の処理が同時に走ります。
- Room接続
- storage同期
- editor初期化
- presence同期
- layout計算
このうちどれか1つでも重くなると、ユーザーは「ページが遅い」と感じます。
今回の実装を通して、すべての機能を最初から同時に有効化するのではなく、まず主要なコンテンツを表示し、補助的な機能は準備状態に応じて段階的に有効化するほうが安定することを学びました。
セキュリティ
権限管理は、クライアント側のUIだけで制御してはいけないと改めて感じました。
VIEWERに編集ボタンを表示しないだけでは不十分で、サーバー側のクエリやAPIレベルでも必ず権限を確認する必要があります。
また、公開ドキュメントは単純なidではなく、tokenベースでアクセスするようにしました。
公開状態と共有トークンを分けておくことで、将来的にリンクの再発行や公開停止もしやすくなります。
認証にはNextAuthを使い、credentialsログインではbcryptでパスワードをハッシュ化して保存しました。
さらに、登録時にはCloudflare Turnstileを使ってbot対策も行いました。
UX
Tree UIの問題を通して、UXは単に「正しいデータを表示する」だけでは不十分だと感じました。
技術的には、現在のページの ancestorPath だけを開いても間違いではありません。しかしユーザーは、一度開いたナビゲーションの状態がページ移動後も維持されることを期待します。
そのため、Treeのopen状態をURLや現在のページパスだけに依存させるのではなく、ユーザー操作の状態として別に管理するほうが自然でした。
リアルタイムカーソルも同じです。
単に座標を送信するだけでは不十分で、すべてのユーザーが同じ基準座標を共有して初めて、正確な共同編集体験になります。
まとめ
このプロジェクトを通して、リアルタイム共同編集は「WebSocketで状態を同期すれば終わり」という単純なものではないと実感しました。
実際には、以下のような要素がすべて関係してきます。
- 権限モデル
- ドキュメント構造
- エディタの初期化タイミング
- presence同期
- layout計算
- レンダリング最適化
- 共有リンクの安全性
- ユーザーのナビゲーション体験
特に Liveblocks と BlockNote を組み合わせる中で、リアルタイム機能は「動くこと」だけでなく、「いつ接続し、いつレンダリングし、いつユーザーに見せるか」が重要だと学びました。
まだ改善できる点はありますが、今回のプロジェクトを通して、リアルタイム共同編集エディタを実装する上で考えるべき構造的な課題をかなり経験できました。
Links
- email: tjehdtns03@gmail.com
- Demo: https://teamspace.dev/
