Next.js + Supabaseでリアルタイムチャットを作ろう

先日は『Next.js』と『Supabase』を利用してTodoアプリを作ってみましたが、本日はリアルタイムに情報が更新されるチャットルームを作成します。

昔からあるような『利用者にリロードを強制するチャットルーム』であれば、レガシーなシステムでも作れるでしょうが、
このようにリアルタイム性が求められるアプリでは、『Next.js』『Supabase』構成の利点が遺憾なく発揮されます。

なお末尾ではgithubの公開も行っていますので、全体の構成を確認されたい方は、ぜひそちらも御覧ください。

目次

事前準備

①:Supabaseプロジェクトの作成

Supabaseにログイン(登録していなければアカウント登録から)して、
トップページの「New Project」を押します。

すると、下記の様な画面が表示されます。


適当なプロジェクト名とデータベースのパスワードを入れて新しいプロジェクトを作成しましょう。
※Regionはできれば自分の住んでいる地域の近くがいいです。私は日本にしました。

②:Next.jsプロジェクトの作成

任意のディレクトリで、

npx create-next-app -e with-supabase

と打ち込み、
対話的にアプリケーションの名前の設定をしてプロジェクトの作成を行いましょう。

次に環境変数の設定を行います。
作成されたNext.jsプロジェクトの直下に.env.local.exampleファイルがあるので、
このファイルを.env.localにリネームし、
SupabaseのサイトのProject Settings→APIにある、Project URLとAPI Keyをそれぞれ.env.localにコピペしてください。

# Update these with your Supabase details from your project settings > API
# https://app.supabase.com/project/_/settings/api
NEXT_PUBLIC_SUPABASE_URL=Project URL
NEXT_PUBLIC_SUPABASE_ANON_KEY=APIキー

その後、

npm run dev

を実行して添付のような画面が表示されればプロジェクトの作成成功です。

Supabaseでテーブル作成

今回は5chのような匿名性のあるチャットアプリを作るため、下記のようなテーブルの列を持つチャット情報のテーブルが必要になります。

  • メッセージのid
  • 作成日時
  • メッセージの内容
  • ユーザのid
  • ルーム名(チャンネル名)
  • ユーザの名前

これを元にテーブルを作成しましょう。

SupabaseのDashboardのTable EditorメニューからNew Tableボタンを押します。
すると新規テーブルの設定画面が出るので、下記画像のように設定してください。

今回は認証機能を利用せずシンプルにTodoアプリを作りたいため、RLSの有効化チェックをOFFにしています。
※本来はONにすべき機能なので、プロダクトとして公開する際は認証機能を実装した上で有効化して安全に利用しましょう。
また、リアルタイム機能を使いたいため、「Enable Realtime」の有効化チェックを入れる必要があります。
なおNext.js+Supabaseでの認証機能実装方法は、こちらの記事でご紹介しています。https://note.com/embed/notes/n168e87864291

これでテーブルの作成はできました。

Next.js側の実装

①:必要なファイルの作成

今回作りたい画面に合わせて、プロジェクトのファイル構成を変更します。
現状のファイル構成が下記のようになっているかと思いますが、まずは不要なファイルを削除し、必要なファイルと入れ替えて行きましょう。

不要なファイル削除&入れ替え後



削除するファイルは、

  • app/auth/callback/route.ts
  • app/auth/login/page.tsx
  • componentsフォルダの直下すべて
  • util/supabase直下すべて
  • middleware.ts

になります。

新たに作成するファイルは、

  • app/chats/page.tsx
  • components/chats/chat.tsx
  • components/date.tsx
  • components/header.tsx
  • components/threadLink.tsx
  • utils/supabase/supabase.ts
  • types/supabasetype.ts

になります。(一旦ファイルを作成するのみで大丈夫です。中身は後で入れます)多いので一個ずつ画像を見ながら作成すると良いと思います。

では、各ファイルの中身を作成する前に下準備から行いましょう。

②:下準備

SupabaseのDBとの連携

Next.jsで実装するためにまずはDatabaseの型を生成しましょう。

下記コマンドを入力し、Supabase CLI上でログインします。
(対話的に進めていけばログイン出来ます。)

npx supabase login

その後、Databaseの型生成用のコマンドを実行します。
($PROJECT_IDは、SupabaseのDashboardでプロジェクトを選択した際のURLのhttps://supabase.com/dashboard/project/以下の文字列です。)

npx supabase gen types typescript --project-id "$PROJECT_ID" --schema public > types/supabasetype.ts

これを実行すると、supabasetype.tsのファイルの中身が生成されていることが確認出来ます。

utils/supabase/supabase.ts

Databaseにアクセスするため、事前にsupabase.tsの中身を作成します。
先程生成したsupabasetype.tsもここで利用しています。

import { createClient } from '@supabase/supabase-js'
import { Database } from '@/types/supabasetype'

export const supabase = createClient<Database>(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
);

.env.localに入力したURLとAPIキーがここで利用されます。

app/globals.css

tailwindcssのインポート以外は全て消しておきます。

@tailwind base;
@tailwind components;
@tailwind utilities;

③:基本ページの作成

今回のアプリの本質とは関係ないですが、大元のレイアウトを作成します。(説明は割愛します)

app/layout.tsx

import Header from '@/components/header'
import './globals.css'

const defaultUrl = process.env.VERCEL_URL
  ? `https://${process.env.VERCEL_URL}`
  : 'http://localhost:3000'

export const metadata = {
  metadataBase: new URL(defaultUrl),
  title: 'Next.js and Supabase Starter Kit',
  description: 'The fastest way to build apps with Next.js and Supabase',
}

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="ja">
      <body className="bg-background text-foreground">
        <Header></Header>
        <main className="pt-14 min-h-screen flex flex-col items-center">
          {children}
        </main>
      </body>
    </html>
  )
}

app/page.tsx

各ルームに遷移するためのリンクを配置する、トップページになります。

import RoomLink from '@/components/roomLink'

export default async function Index() {

  return (
    <div className="flex-1 w-full flex flex-col items-center">
      <h1 className="text-3xl font-bold pt-6 pb-10">リアルタイムチャットルーム</h1>
      <ul>
        <RoomLink channelName='Room1' roomName='ルーム1'></RoomLink>
        <RoomLink channelName='Room2' roomName='ルーム2'></RoomLink>
        <RoomLink channelName='Room3' roomName='ルーム3'></RoomLink>
        <RoomLink channelName='Room4' roomName='ルーム4'></RoomLink>
        <RoomLink channelName='Room5' roomName='ルーム5'></RoomLink>
      </ul>
    </div>
  )
}

components/header.tsx

ページの一番上に常に表示されるヘッダーです。
今回はシンプルにトップページへのリンクだけを表示しています。

import Link from 'next/link'

export default function Header() {
    return (
        <header className="p-4 border-b-2 border-gray-300 fixed w-full bg-white">
            <ul className="w-full max-w-xl m-auto flex font-medium flex-row">
                <li>
                    <Link className="text-gray-700 hover:text-blue-700" href="/">Home</Link>
                </li>
            </ul>
        </header>
    )
}

ヘッダーまで含め、トップページ全体は下記のような見た目になります。

④:チャットアプリ部分

app/chats/page.tsx

チャットアプリ部分のコード全体です。
長いので各関数の実装などについては次の章で説明します。

"use client"
import { Database } from "@/types/supabasetype"
import { useEffect, useState } from "react"
import { supabase } from "@/utils/supabase/supabase"
import { v4 } from "uuid"
import { useSearchParams } from "next/navigation"
import ChatUI from "@/components/chats/chat"

export default function Chats() {
  const searchParams = useSearchParams()
  let channelName = searchParams.get("channel_name")!!
  const [inputText, setInputText] = useState("")
  const [inputName, setInputName] = useState("")
  const [messageText, setMessageText] = useState<Database["public"]["Tables"]["Chats"]["Row"][]>([])

  const fetchRealtimeData = () => {
    try {
      supabase
        .channel(channelName)
        .on(
          "postgres_changes",
          {
            event: "*",
            schema: "public",
            table: "Chats",
          },
          (payload) => {
            if (payload.eventType === "INSERT") {
              const { created_at, id, message, uid, channel, name } = payload.new
              setMessageText((messageText) => [...messageText, { id, created_at, message, uid, channel, name }])
            }
          }
        )
        .subscribe()

      return () => supabase.channel(channelName).unsubscribe()
    } catch (error) {
      console.error(error)
    }
  }

  // 初回のみ実行するために引数に空の配列を渡している
  useEffect(() => {
    (async () => {
      let allMessages = null
      try {
        const { data } = await supabase.from("Chats").select("*").eq('channel', channelName).order("created_at")

        allMessages = data
      } catch (error) {
        console.error(error)
      }
      if (allMessages != null) {
        setMessageText(allMessages)
      }
    })()
    fetchRealtimeData()
  }, [])

  const onSubmitNewMessage = async (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault()
    if (inputText === "") return
    try {
      let userID = localStorage.getItem("uid")
      if (userID == undefined) {
        userID = v4()
        localStorage.setItem("uid", userID)
      }
      let userName = "匿名"
      if (inputName !== "") {
        userName = inputName
      }
      await supabase.from("Chats").insert({ message: inputText, uid: userID, channel: channelName, name: userName })
    } catch (error) {
      console.error(error)
    }
    setInputText("")
  }

  return (
    <div className="flex-1 w-full flex flex-col items-center p-2">
      <h1 className="text-3xl font-bold pt-5 pb-10">{channelName}</h1>
      <div className="w-full max-w-3xl mb-10 border-t-2 border-x-2">
        {messageText.map((item, index) => (
          <ChatUI chatData={item} index={index}></ChatUI>
        ))}
      </div>

      <form className="w-full max-w-md pb-10" onSubmit={onSubmitNewMessage}>
        <div className="mb-5">
          <label htmlFor="name" className="block mb-2 text-sm font-medium text-gray-900">名前(省略可)</label>
          <input type="text" id="name" className="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg 
                focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"
            name="name" value={inputName} onChange={(event) => setInputName(() => event.target.value)}></input>
        </div>
        <div className="mb-5">
          <label htmlFor="message" className="block mb-2 text-sm font-medium text-gray-900">投稿内容</label>
          <textarea id="message" name="message" rows={4} className="block p-2.5 w-full text-sm text-gray-900
                 bg-gray-50 rounded-lg border border-gray-300 focus:ring-blue-500 focus:border-blue-500"
            placeholder="投稿内容を入力" value={inputText} onChange={(event) => setInputText(() => event.target.value)}>
          </textarea>
        </div>

        <button type="submit" disabled={inputText === ""} className="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm w-full sm:w-auto px-5 py-2.5 text-center disabled:opacity-25">
          送信
        </button>
      </form>
    </div>
  )
}

見た目は以下のようになります。

⑤:チャットアプリ部分(続き)

ここからは上記のチャットアプリ部分の実装の説明になります。

URLパラメータの取得

トップページから遷移する際にパラメータにルーム名を渡してどのルームに表示すべきチャットか?を分岐させています。

const searchParams = useSearchParams()
let channelName = searchParams.get("channel_name")!!

状態管理

  • 投稿内容
  • 名前
  • 表示する投稿一覧の配列

の3つを下記で管理しています。

const [inputText, setInputText] = useState("")
const [inputName, setInputName] = useState("")
const [messageText, setMessageText] = useState<Database["public"]["Tables"]["Chats"]["Row"][]>([])

messageTextの型が分かりづらいですが、生成したデータベースのスキーマからチャットテーブルの列の型情報を取得しているのがポイントです。

チャットデータの取得

下記がリアルタイムなチャットのイベントリスナーになっています。

const fetchRealtimeData = () => {
    try {
      supabase
        .channel(channelName)
        .on(
          "postgres_changes",
          {
            event: "*",
            schema: "public",
            table: "Chats",
          },
          (payload) => {
            if (payload.eventType === "INSERT") {
              const { created_at, id, message, uid, channel, name } = payload.new
              setMessageText((messageText) => [...messageText, { id, created_at, message, uid, channel, name }])
            }
          }
        )
        .subscribe()

      return () => supabase.channel(channelName).unsubscribe()
    } catch (error) {
      console.error(error)
    }
  }

postgres_changesとある通り、DBの変更を検知しています。
今回のアプリではチャットが追加された際にそれをUIに反映できれば良いため、
INSERTイベントのみに絞って反映処理を行っています。
[…messageText, { id, created_at, message, uid, channel, name}]
のように書くことで過去のデータ+ 追加データをまとめて配列にしています。

(payload) => {
            if (payload.eventType === "INSERT") {
              const { created_at, id, message, uid, channel, name } = payload.new
              setMessageText((messageText) => [...messageText, { id, created_at, message, uid, channel, name }])
            }
          }

初回起動時のUI反映

// 初回のみ実行するために引数に空の配列を渡している
  useEffect(() => {
    (async () => {
      let allMessages = null
      try {
        const { data } = await supabase.from("Chats").select("*").eq('channel', channelName).order("created_at")

        allMessages = data
      } catch (error) {
        console.error(error)
      }
      if (allMessages != null) {
        setMessageText(allMessages)
      }
    })()
    fetchRealtimeData()
  }, [])

下記でチャットの全データから今回のルームのものをフィルタリングし、作成日時順に並べるクエリを作成しています。

const { data } = await supabase.from("Chats").select("*").eq('channel', channelName).order("created_at")

UIの反映まで終わった後、下記でリアルタイムイベントのリスナーを起動しましょう。

fetchRealtimeData()

メッセージの投稿処理

メッセージを投稿フォームから送信する際に下記の処理が行われます。

const onSubmitNewMessage = async (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault()
    if (inputText === "") return
    try {
      let userID = localStorage.getItem("uid")
      if (userID == undefined) {
        userID = v4()
        localStorage.setItem("uid", userID)
      }
      let userName = "匿名"
      if (inputName !== "") {
        userName = inputName
      }
      await supabase.from("Chats").insert({ message: inputText, uid: userID, channel: channelName, name: userName })
    } catch (error) {
      console.error(error)
    }
    setInputText("")
  }


著名な匿名掲示板ではIPアドレスやデバイス名などをもとにハッシュ値を生成して割当てているようですが、詳細にテストを行わないとIPアドレスなどの個人情報が流出する可能性があるため、今回はユーザIDをランダムに生成するのみとしています。

ランダムなIDの生成にはnpmのuuidパッケージを利用しました。
https://www.npmjs.com/package/uuid
下記のコマンドでインストールしましょう。

npm i uuid
npm i --save-dev @types/uuid

下記が実際のコードになります。
ローカルストレージにユーザIDがなければランダムに生成し、ローカルストレージに設定します。

let userID = localStorage.getItem("uid")
if (userID == undefined) {
userID = v4()
localStorage.setItem("uid", userID)
}

ユーザ名に関しては入力がある場合はそれを、ない場合は「匿名」としています。

let userName = "匿名"
if (inputName !== "") {
userName = inputName
}

最後にSupabaseのチャットテーブルに追加しています。

await supabase.from("Chats").insert({ message: inputText, uid: userID, channel: channelName, name: userName })

⑥:その他のUI

components/chats/chat.tsx

チャット一つ一つのUIを作成しています。

import { Database } from "@/types/supabasetype"
import DateFormatter from "@/components/date"

type Props = {
  chatData: Database["public"]["Tables"]["Chats"]["Row"],
  index: number
}

export default function ChatUI({ chatData, index }: Props) {
  return (
    <div key={chatData.id} className="p-2 border-b-2">
      <div className="flex">
        <p className=" pr-2">{index + 1}</p>
        <h2 className="font-medium text-gray-900 truncate">{chatData.name}</h2>
      </div>
      <div className="flex items-center justify-between">
        <p className="text-sm text-gray-500"><DateFormatter timestamp={chatData.created_at}></DateFormatter></p>
        <p className="w-32 text-sm text-gray-500 truncate">ID:{chatData.uid}</p>
      </div>
      <p className="mt-1 text-gray-600 whitespace-pre-wrap">{chatData.message}</p>
    </div>
  )
}

見た目は以下のような形です。

components/date.tsx

return内でJSの処理を詳細に書くことが出来ないため、作成日時を日本時間にする処理をコンポーネント化しています。

type Props = {
    timestamp: string
}

export default function DateFormatter({ timestamp }: Props) {
    const date = new Date(timestamp)
    var jstDate = date.toLocaleString("ja-JP", { timeZone: "Asia/Tokyo" })

    return (
        <>
            {jstDate}
        </>
    )
}

components/roomLink.tsx

各ルームへのリンクです。

import Link from 'next/link'

type Props = {
  channelName: string,
  roomName: string,
}

export default function RoomLink({ channelName, roomName }: Props) {
  return (
    <li className='mb-4'>
      <Link className='text-gray-700 border-b-2 border-gray-700 hover:border-blue-700 hover:text-blue-700 text-xl' href={{
        pathname: '/chats',
        query: { channel_name: channelName },
      }}>{roomName}</Link>
    </li>
  )
}

⑦:実際に利用してみる

ここまででコードは作成出来たため、実際にチャットルームを利用し、リアルタイムに変更が行われることを確認しましょう。

まずはルーム1に入ってみます。

まずは名前を入れずに投稿してみます。


送信ボタンを押した瞬間に画面上でも投稿が追加されるのがわかるかと思います。


Supabaseのダッシュボード上でもテーブルに行が追加されたことがわかります。


では、自分以外が投稿した場合も確認してみましょう。
別のブラウザやアカウントからチャットアプリを開いてみます。
同じようにルーム1に入ると、先程投稿した内容が確認できます。


今度は名前をつけて投稿してみましょう。


別のIDでの投稿がされていることがわかります。


初めに投稿したウインドウに戻ると、ロードの必要なく、リアルタイムに投稿が反映されていることを確認できます。

他にも別のルームに投稿したり、同じIDで投稿して見たりと確認してみましょう。
リアルタイム性のあるチャットを作れたことがわかるかと思います。

リアルタイム性の高いアプリを作れると、開発の幅が広がる。

本日つくったアプリの、コード全体はこちらです。
https://github.com/TodoONada/nextjs-supabase-realtime

今回は『リアルタイム性』のあるアプリを作ることが出来ました。
『リアルタイム性』は、現在WEBサービスを作るに当たって非常に重要な要素です。
例えば金融マーケット関連のサービスを作るのであれば、リアルタイム性は情報の確度を左右するものになるでしょうし、最近ではニュースサイトですら、リアルタイムに更新がおこなれます。

更にはゲームコミュニーケーションツール、といった物を作りたいのであれば、リアルタイムな情報更新は必要不可欠と言えます。
ユーザーがいちいち更新しなければいけないのか、それともリアルタイムに情報が更新されるのか、といった違いは、ユーザーの体験に非常に大きな差を生み出すでしょう。

逆に言えば、前回のTodoアプリで学んだ『CRUD』、更に過去記事でご紹介した『認証機能』、そして今回の記事で学んだ『リアルタイム性』
これらを組み合わせることによって、現在一線で必要とされるアプリですら、作ることが可能なのです。

とは言っても、もちろんまだまだ学ぶことは多いため、『この世に必要なあらゆるサービスを作る』と言ったことは難しいでしょうが、
それでも『リアルタイムアプリケーションを作ることが出来た』という実績は、開発者の階段を登り始めたものとして、誇って良いものだと思います。

今後も『TodoONada株式会社』では、Next.jsやSupabaseを利用したプロダクトや技術について、ご紹介したいと思います。
ぜひそちらもご覧いただき、エンジニア技術向上の一助としていただければ幸いです。

SupabaseのTIPSリンク集

お問合せ&各種リンク

presented by

  • システム開発、アプリ開発
  • マッチングアプリ開発
  • インフラ構築支援等、なんでもご相談ください。
よかったらシェアしてね!
  • URLをコピーしました!
目次