はじめに
こんにちは!
未経験からエンジニアを目指して学習中のReoです。
今回は作成しているマッチングアプリ風転職支援サイトの更新を行っていきたいと思います。
内容としては、Send Birdという外部APIを活用してマッチングした企業・求職者同士がチャットできるようにします。
これを行うことで、使用したことのあるSupabaseの外部API利用以外の実装経験が積めるので、外部APIの理解がより深まること間違いなしです!
いざ実装!
まずは、Send Bird側でアカウント登録を行います。
次に、npmでSendbirdのUIKitをインストールしていきます。
インストールの選択肢として、以下の2パターンがあるようなので違いを理解していきます。
npm install @sendbird/uikit-react or @sendbird/chat
ライブラリ名 | 用途 | 開発難易度 | カスタマイズ性 | UI付きか |
---|---|---|---|---|
@sendbird/uikit-react |
UI付きチャットの即導入 | 低 | △ やや低め | ✅ あり |
@sendbird/chat |
チャット機能の低レベルAPI | 中〜高 | ◎ 高い | ❌ なし |
今回は、あくまで外部APIとの連携がメインになってくるので、@sendbird/uikit-reactを使うことにします。
ここでインストールを試みると、以下のエラーが表示されました。
どうやらこのエラーは「依存関係のバージョンの衝突(dependency conflict)」によって発生しているようです。
Reactのバージョン(19.1.0)と、react-day-picker@8.10.1 が要求するバージョン(React 16〜18)に不整合があるためです。
→ バージョンが合わず、npm が依存関係の解決に失敗
以下を参考にして「--legacy-peer-deps 」をつければインストールはできることを確認しました。
バージョンの不整合を無視してインストールできるだけなので、後々エラーを起こす可能性があるようですが、「--legacy-peer-deps 」でインストールしてみようと思います。
npm install @sendbird/uikit-react --legacy-peer-deps
実行してみると、無事インストール完了したようです。
ライブラリに追加されていることを確認できました。
次に「.env_local」ファイルで以下のSendBirdのIDとTOKENの設定を行いました。
これでアプリ側のSendBirdの環境変数設定が完了です。
NEXT_PUBLIC_SENDBIRD_APP_ID=XXXXXXXXXXXX
SENDBIRD_API_TOKEN=XXXXXXXXXXXXXX
.env.localファイルとは?
パソコンにしか見えない「ひみつのメモ帳」です。
アプリを作るときに、人に見せたくない情報(パスワードやAPIキー)を入れておく場所です。
なにに使うの?
例えば、こんなことがあったとします:
「チャットアプリを作るけど、Sendbirdというサービスの“鍵”を使わないといけない!」
その“鍵”(APIキーやIDなど)をプログラムに直接書くのは危ないので、
.env.local というファイルにこっそり書いておく、というわけです。
注意点
-
.env.local はぜったいにGitにアップしないでください!(他の人に秘密がバレます)
-
.gitignore に .env.local が入っていればOKです
-
もし「画面(ブラウザ)でも使いたい」場合は、変数名を NEXT_PUBLIC_ で始めます(TOKENやROLEKEYなどの読み込まれたくない情報にはつけない)
しかし、結局実装のイメージ的にuikit-reactではなく、以下のコマンドでSDK単体をインストールし直すことにしました。
理由としては、UIKITではUIのカスタマイズ性に欠けており、事前に用意していたUIを使用できないと判断したからです。
npm install @sendbird/chat --legacy-peer-deps
下準備は完了したので、これからは全体の実装の流れを整理します。
✅ 全体の実装フロー
① マッチング成立時にSendbirdのチャネル作成 Supabaseのmatchesテーブルにchannel_urlカラムを追加。(完了) sendLikeActionやmatch判定ロジックの中で、マッチが成立した場合に以下を行う: SendbirdのAPIを叩いてチャネル作成(ユーザーIDはSupabaseと共通のIDを使用)。 生成されたchannel_urlをSupabaseのmatchesテーブルに保存。② chat/page.tsx にSendbird SDKを導入して、チャットウィンドウでチャネルに接続
sendbird-uikit or sendbird-chat SDKで初期化。
ログインユーザーをSendbirdにログイン。
conversationsをマッチ済みユーザー一覧 + channel_urlに置き換える。
③ chat/[partnerId]/page.tsx で個別チャットルームを表示
Supabaseのmatchesテーブルからchannel_urlを取得。
Sendbird SDKで該当チャネルに参加し、MatchedChatで表示。
以下のようにファイルを実装していきます。
Sendbird SDK初期化ユーティリティの作成
マッチ時にSendbirdチャネル作成&保存
chat/page.tsxのチャネル一覧にSendbirdチャネルを使う
実装中に以下の警告文が表示されました。
①ChatConversationは見つかりません
理由:chat-dummy-data.tsで定義されているのに、インポートしていなかった。
→ファイル上部にインポート文を記載することで解消。
②プロパティ 'groupChannel' は型 'SendbirdChat' に存在しません
理由:
groupChannel は Sendbird SDK v4 で GroupChannelModule を使用したときに拡張されるプロパティですが、
型定義 (SendbirdChat) 上は デフォルトで groupChannel が存在しないように見える ことがあります。
これは型補完の限界によるもので、実行時には正しく存在します。
create-match-channel.tsの実装
サーバーサイドで動作するアクションで、マッチングが成立した際に呼び出されます。
Supabaseからマッチング情報を取得し、createSendbirdChannelを呼び出してSendbird上にチャットチャンネルを作成し、そのチャンネルURLをSupabaseのmatchesテーブルに保存する役割。
lib/sendbird-chat.tsの実装
Sendbirdを使ったチャットの主要な機能であるメッセージの取得(fetchMessages)と送信(sendMessageToChannel)を実装。
getSendbirdClientを内部で利用して、特定のチャンネルのメッセージ履歴を取得したり、新しいメッセージを送信したりします。
lib/supabaseChat.tsの実装
Supabaseのmatchesテーブルから、指定されたユーザーIDが関わっているマッチング情報(特にSendbirdのchannel_url)を取得するためのgetMatchedConversations関数を定義。チャットページで会話リストを表示する際に使われます。
実際にローカル環境でうまく機能するかを確認します。(以降コマンド省略)
npm run dev
ここで問題が、、チャット画面に遷移しようとすると、読み込み中のまま進みません。
原因と変更内容
🧠原因チャット画面は、以下のような「複数の準備」ができて初めて正しく表示されます:
- ログインしているユーザーの情報取得(認証)
- Supabaseからマッチング済みのチャネル一覧を取得
- Sendbirdからチャット履歴を取得
- 会話相手の情報(アイコンや名前など)を取得
これらには 少し時間がかかることがあるため、処理が完了する前に画面を表示しようとすると、「データがない」状態で表示されてしまい、画面が固まる原因になります。
✏️ 変更したコードの中身
- try-catch-finally を使って安全に処理を実行
try {
// チャネルやメッセージの取得などの準備
} catch (error) {
// もしエラーが出ても止まらないように
console.error("エラー:", error)
} finally {
// 最後に「準備完了」のフラグを立てる
setIsInitialized(true)
} - 初期化が終わるまで表示を待つようにした
if (isLoading || !isInitialized) {
return <読み込み中... />
}
次に、別の問題が発生しました。
内容としては、マッチングした後にSendBirdのチャットが作成されないことです。
原因を紐解いていくと、そもそもユーザーのSendBirdのアカウント作成が行われていないことがわかりました。(app/register/jobseeker/page.tsxというクライアントで利用しようとしていた)
そのため、app/actions/auth.ts の signUpJobseekerAction
関数内でSendBirdユーザーを作成するように変更します。
修正手順
1. lib/sendbird-api.ts に createSendbirdUser 関数を(再度)追加します。ただし、今回はサーバーサイドで実行されることを想定し、process.env.SENDBIRD_API_TOKEN を使用します。(NEXT_PUBLICをつけない) 2. app/actions/auth.ts の signUpJobseekerAction 関数を修正し、Supabaseユーザー作成が成功した後に createSendbirdUser を呼び出すようにします。 3. app/register/jobseeker/page.tsx から createSendbirdUser 関数と関連する呼び出しを削除します。(不要になった)しかし、いまだにSendBirdアカウントは作成されません。
さらに調査を行います。するとアカウント作成時に実行されていた関数がsignUpJobseekerAction
ではなく、 createJobseekerProfileAction
だったことがわかりました。
修正手順
-
app/actions/profile.ts
の先頭でcreateSendbirdUser
をインポートします。 -
createJobseekerProfileAction
関数内で、supabaseAdmin!.from("jobseeker_profiles").insert(profileData)
の成功後、createSendbirdUser
を呼び出すコードを追加します。userId
は既に取得済みです。nickname
としてformData.get("name") as string
を、profileUrl
としてphotoUrl
を渡します。app/actions/profile.ts
の確認が完了しました。
想定としては、同じIDで管理する予定だったので、まずは同じIDである必要性について再確認します。
同じIDが良い理由
1. マッチングとの整合性が取りやすい Supabaseのmatchesテーブルなどで管理しているユーザーID(例:user_id_jobseeker, user_id_company)と Sendbirdのチャネルに参加するユーザーIDが一致していれば、 → どのチャネルに誰が関わっているかが一目で分かります。-
チャネル作成・アクセス制御が簡単になる
相手のIDだけでチャネルを特定できる -
ログイン時のSendbird接続がスムーズ
-
二重管理の必要がなくなる
ここで改めてIDを確認すると、SendBirdとSUPABASEのUsersのIDで一致していました。
SUPABASE側でIDを確認していた箇所がjobseeker_profiles
テーブルだったことが不一致だと勘違いした理由でした。
企業側でも同じコードを実装してから、現状の実装でマッチングまで行なってチャンネルが作成されるかテストを行います。
テストを行うと、マッチング後にグループチャンネルが作成されることを確認できました。
しかし、チャット欄に遷移しても「マッチングしたユーザーがいません」と表示されてしまします。
マッチング成立後にチャットが開始できない問題を修正しました。
原因は、チャットページがマッチ一覧を取得する際に、自身の役割(求職者か企業か)を正しく認識できず、データベースへの問い合わせに失敗していたことでした。
そのため、マッチングなしの判定になってしまっていました。
この問題を解決するため、lib/supabaseChat.ts
ファイルを修正し、ユーザーの役割を正しく取得してからデータベースに問い合わせるようにロジックを変更しました。
次に実際にチャットを送信できるのか確認します。
すると、送信時に以下のエラーが表示されてしまいました。
RangeError: Invalid time value
at formatDistance (webpack-internal:///(app-pages-browser)/./node_modules/date-fns/formatDistance.js:107:34)
at formatDistanceToNow (webpack-internal:///(app-pages-browser)/./node_modules/date-fns/formatDistanceToNow.js:93:78)
at formatMessageTime (webpack-internal:///(app-pages-browser)/./lib/chat-dummy-data.ts:26:116)
at eval (webpack-internal:///(app-pages-browser)/./components/chat/ChatSidebar.tsx:72:130)
at Array.map (<anonymous>)
at ChatSidebar (webpack-internal:///(app-pages-browser)/./components/chat/ChatSidebar.tsx:21:37)
at ChatPage (webpack-internal:///(app-pages-browser)/./app/chat/page.tsx:235:104)
at ClientPageRoot (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/client/components/client-page.js:20:50)
修正内容
原因
Sendbirdから取得したメッセージのタイムスタンプが、コンポーネント間で受け渡される際に Date
オブジェクトから文字列に変換され、日付フォーマット関数が無効な値を処理しようとしたことでした。
修正
app/chat/page.tsxにおいて、Sendbirdから取得したメッセージのタイムスタンプを、new Date() を用いて明示的に Date オブジェクトに変換するようにしました。これにより、データ転送中にタイムスタンプが文字列に変換されてしまっても、エラーが発生しなくなります。
型が不明確だった msg パラメータに ChatMessage 型を定義し、TypeScriptの型エラーを解消しました。
app/chat/page.tsx
ファイルを修正し、メッセージデータをコンポーネントに渡す直前に、タイムスタンプを明示的に Date
オブジェクトに再変換する処理を追加しました。
次はチャットを送信しようとした際に、別のエラーが発生しました。
Error: Encountered two children with the same key, `undefined`. Keys should be unique so that components maintain their identity across updates. Non-unique keys may cause children to be duplicated and/or omitted — the behavior is unsupported and could change in a future version.
at createUnhandledError (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/client/components/errors/console-error.js:27:71)
at handleClientError (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/client/components/errors/use-error-handler.js:45:56)
at console.error (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/client/components/globals/intercept-console-error.js:47:56)
at eval (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/compiled/react-dom/cjs/react-dom-client.development.js:7113:23)
at runWithFiberInDEV (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/compiled/react-dom/cjs/react-dom-client.development.js:1511:30)
at warnOnInvalidKey (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/compiled/react-dom/cjs/react-dom-client.development.js:7112:13)
at reconcileChildrenArray (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/compiled/react-dom/cjs/react-dom-client.development.js:7154:23)
at reconcileChildFibersImpl (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/compiled/react-dom/cjs/react-dom-client.development.js:7502:30)
at eval (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/compiled/react-dom/cjs/react-dom-client.development.js:7607:33)
at reconcileChildren (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/compiled/react-dom/cjs/react-dom-client.development.js:8049:13)
at beginWork (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/compiled/react-dom/cjs/react-dom-client.development.js:10293:13)
at runWithFiberInDEV (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/compiled/react-dom/cjs/react-dom-client.development.js:1511:30)
at performUnitOfWork (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/compiled/react-dom/cjs/react-dom-client.development.js:15120:22)
at workLoopSync (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/compiled/react-dom/cjs/react-dom-client.development.js:14944:41)
at renderRootSync (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/compiled/react-dom/cjs/react-dom-client.development.js:14924:11)
at performWorkOnRoot (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/compiled/react-dom/cjs/react-dom-client.development.js:14411:13)
at performSyncWorkOnRoot (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/compiled/react-dom/cjs/react-dom-client.development.js:16290:7)
at flushSyncWorkAcrossRoots_impl (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/compiled/react-dom/cjs/react-dom-client.development.js:16138:21)
at processRootScheduleInMicrotask (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/compiled/react-dom/cjs/react-dom-client.development.js:16175:7)
at eval (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/compiled/react-dom/cjs/react-dom-client.development.js:16309:13)
at eval (webpack-internal:///(app-pages-browser)/./components/chat/ChatMessages.tsx:65:120)
at Array.map (<anonymous>)
at eval (webpack-internal:///(app-pages-browser)/./components/chat/ChatMessages.tsx:65:30)
at Array.map (<anonymous>)
at ChatMessages (webpack-internal:///(app-pages-browser)/./components/chat/ChatMessages.tsx:38:43)
at ChatWindow (webpack-internal:///(app-pages-browser)/./components/chat/ChatWindow.tsx:144:88)
at ChatPage (webpack-internal:///(app-pages-browser)/./app/chat/page.tsx:287:108)
at ClientPageRoot (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/client/components/client-page.js:20:50)
修正内容
修正
・lib/sendbird-chat.ts の堅牢化: メッセージ送信後、Sendbirdサーバーから返されるデータに messageId が含まれていない場合、これを明確なエラーとして処理するように変更しました。以前は、id がない不正なメッセージオブジェクトが作成され、エラーの原因となっていました。
次のチャット送信時に以下のエラーが発生し、日付や送信文章が「Invalid Date」と表示されてしまいました。
Error: Failed to send message or receive messageId.
at sendMessageToChannel (webpack-internal:///(app-pages-browser)/./lib/sendbird-chat.ts:45:15)
at async handleSendMessage (webpack-internal:///(app-pages-browser)/./app/chat/page.tsx:184:33)
修正内容
原因
Error: Failed to send message or receive messageId.
というエラーは、まさに先ほど lib/sendbird-chat.ts
に仕込んだエラーチェック throw new Error("Failed to send message or receive messageId.")
が作動したことを意味します。
これは、Sendbirdの channel.sendUserMessage(params)
が返す sentMessage
オブジェクトに、messageId
が含まれていない(null
、0
、または undefined
である)ことを明確に示しています。
なぜ messageId
がないのか?
Sendbird SDKの sendUserMessage
は、メッセージ送信をリクエストした後、サーバーからの完全な応答を待たずに、ローカルで生成された一時的なメッセージオブジェクトを返すことがあります。この一時的なオブジェクトには、まだサーバーで採番されていないため、messageId
が 0
や null
になっている場合があります。
修正
lib/sendbird-chat.ts の sendMessageToChannel 関数を、Promiseでラップしたコールバックスタイルに書き換えます。これにより、messageId がサーバーで正常に採番され、取得が完了した時点で初めて、そのメッセージオブジェクトを返すようになります。
複数のエラー
・文章送信時にEnterキーで日本語の変換を確定すると、意図せずメッセージが送信されてしまう
・企業側のチャット画面で、チャット対象者の名前が正しく表示されず、企業自身の名前が表示されてしまう
・チャットの吹き出しが左右に正しく表示されず、どちらの発言か分かりにくい問題
・チャットメッセージが送信者ごとにグループ化されてしまい、時系列に表示されていない問題
複数の修正内容
チャット入力コンポーネントを修正し、日本語入力の変換を確定するためにEnterキーを押してもチャットが送信されないようにしました。isComposingという状態を導入し、文字変換中にEnterキーが押された場合はメッセージ送信処理が実行されないように変更しました。
・企業側のチャット画面で、チャット相手の名前が正しく表示されるように app/chat/page.tsx を修正しました。
ログインしているユーザーの種類(企業か求職者か)を正しく判定するようにしました。
チャット相手の情報を取得する際に、Sendbirdのニックネームではなく、データベースに登録されている正しい名前とプロフィール画像を取得するように修正しました。
ChatWindow.tsx でダミーのユーザーIDを使用していたのをやめ、実際のログインユーザーIDを渡すように修正しました。
これにより、ChatMessage.tsx で自分のメッセージか相手のメッセージかを正しく判定できるようになり、自分のメッセージは右側に、相手のメッセージは左側に表示されるようになります。
自分のメッセージの吹き出しの背景色を、より見分けやすいように濃い紫色に変更しました。
チャットのメッセージが送信者ごとに表示され、時系列が分かりにくくなっていた問題を修正しました。
components/chat/ChatMessages.tsx
ファイルを修正し、メッセージを表示する直前に必ずタイムスタンプでソートするように変更しました。これにより、企業と求職者のメッセージが混在していても、常に正しい時系列で表示されるようになります。
完成品
学んだこと・つまずいた点
学んだこと
-
UIKitとSDK単体の違い
-
@sendbird/uikit-react
はUI付きで導入が早いが、UIカスタマイズ性は低め。 -
@sendbird/chat
は低レベルAPIでカスタマイズ自由度が高いが、実装難易度は高め。
-
-
依存関係のバージョン不一致問題
- React 19 と
react-day-picker
の対応バージョンが不一致で--legacy-peer-deps
が必要だった。
- React 19 と
-
.env.local の使い方と管理方法
- APIキーやIDは
.env.local
に保存し、NEXT_PUBLIC_
接頭辞でブラウザ側でも利用可能にできる。
- APIキーやIDは
-
SDK初期化の流れ
-
GroupChannelModule
を利用してSendbirdChat.init()
→connect()
で接続。
-
-
SupabaseとSendbirdのID統一の重要性
- ユーザーIDが一致していると、マッチング管理やチャネル特定が容易になる。
-
マッチ成立時のチャネル作成フロー
- Supabaseの
matches
テーブルにchannel_url
を保存しておき、チャットページで利用する。
- Supabaseの
-
チャット初期化の遅延対策
-
isLoading
/isInitialized
フラグで認証やデータ取得が終わるまで待機表示にする。
-
-
日付のInvalid Date問題
- Next.js のコンポーネント間データ渡しで
Date
が文字列化されるため、new Date()
で再変換が必要。
- Next.js のコンポーネント間データ渡しで
-
React key重複エラーの原因と対策
- 新規送信メッセージは一時ID(例:
Date.now()
)を使い、key
の重複を防ぐ。
- 新規送信メッセージは一時ID(例:
-
sendUserMessageのmessageId未取得問題
- コールバックを使ってサーバー採番後のメッセージを返すことで解決。
-
UX改善ポイント
- 日本語変換中のEnterキーで送信されないように
isComposing
を導入。 - 自分/相手の吹き出しを正しく左右表示するためにログインユーザーIDを利用。
- チャットメッセージをタイムスタンプでソートし、常に時系列順に表示する。
- 日本語変換中のEnterキーで送信されないように
つまづいたこと
- UIKitインストール後にUIカスタマイズが想定より難しく、SDK単体に切り替えた。
-
groupChannel
が型上存在しないエラー(実行時は動くが型補完では未定義)。 - チャット画面が初期化中に固まる問題(フラグ制御で解決)。
- SendBirdユーザー作成の呼び出し箇所が誤っていて、マッチング時にチャネルが作成されなかった。
- SUPABASEの
jobseeker_profiles
のIDとusers
のIDを混同し、ID不一致だと誤認。 - メッセージ送信時に
Invalid Date
やkey
重複エラーが頻発。 -
sendUserMessage
の即時返却でmessageId
が未設定なケースに遭遇。 - 企業側の画面で自分の名前が相手として表示される不具合。
- メッセージ表示が送信者ごとにグループ化され、時系列が崩れる問題。
まとめ
今回はかなりエラーの数が多く、実装まで時間がかかってしまいました。
なのでもう少しスピード感を意識して、実装期限を明確にして行いたいと思いました。
そうすることで、自分の管理能力や実力の認識を確認できると思います!
エラーの修正にはClineを活用しているのですが、まだまだ分からないことが多いので
繰り返し実装を行って、Clineを活用しつつも、修正方針が自分で定められるような知識や経験を積んでいこうと思います!(精進しますということですね…!)
次回は、
未定ですが、もう少し投稿しやすいものにしようかと考えています!
最後まで読んでいただきありがとうございました!