【NextJS】ChatApp with SocketIO

1. 概要

今回はSocketIOを使い、シンプルなチャットアプリを作る内容となります。

対象としては開発を1年程やってて自分で最初から開発してみたい方になります。そのため細かい用語などの説明はしません。

2. nodeのインストール

こちらを参考

3. プロジェクトを作成

下記を実行

npx create-next-app@latest
Need to install the following packages:
create-next-app@15.0.2
Ok to proceed? (y)

✔ What is your project named? … socket-sample
✔ Would you like to use TypeScript? … No / Yes
✔ 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
✔ What import alias would you like configured? … @/*
Creating a new Next.js app in /home/sondon/dev/wsl/nodejs/react/socket-sample.

Using npm.

Initializing project with template: app


Installing dependencies:
- react
- react-dom
- next

Installing devDependencies:
- typescript
- @types/node
- @types/react
- @types/react-dom
- eslint
- eslint-config-next

added 297 packages, and audited 298 packages in 4m

118 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities
Initialized a git repository.

Success! Created socket-sample at /home/sondon/dev/wsl/nodejs/react/socket-sample

4. 必要なライブラリをインストール

下記を実行

npm i --save-dev ts-node nodemon
npm i socket.io socket.io-client

5. ソースコード

5-1. 設定

5-1-1. package.json

{
  "name": "socket-sample",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
    "server:dev": "nodemon server/app.ts"
  },
  "dependencies": {
    "next": "15.0.2",
    "react": "19.0.0-rc-02c0e824-20241028",
    "react-dom": "19.0.0-rc-02c0e824-20241028",
    "socket.io": "^4.8.1",
    "socket.io-client": "^4.8.1"
  },
  "devDependencies": {
    "@types/node": "^20",
    "@types/react": "^18",
    "@types/react-dom": "^18",
    "eslint": "^8",
    "eslint-config-next": "15.0.2",
    "nodemon": "^3.1.7",
    "ts-node": "^10.9.2",
    "typescript": "^5"
  }
}
  • サーバーサイドの起動コマンドを追加

5-1-2. tsconfig.json

{
  "compilerOptions": {
    "target": "ES2017",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "incremental": true,
    "plugins": [
      {
        "name": "next"
      }
    ],
    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "ts-node": {
    "compilerOptions": {
      "module": "CommonJS",
      "paths": {
        "@/*": ["../src/*"]
      }
    }
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
  "exclude": ["node_modules"]
}
  • サーバーサイドのコンパイルのために「ts-node」を追加

5-1-3. nodemon.json

{
  "watch": ["server"],
  "ext": "ts",
  "ignore": ["*.text.ts"],
  "delay": "3",
  "execMap": {
    "ts": "ts-node"
  }
}
  • Reload, automatically.

5-2. フロントサイト

5-2-1. src/common/constants.ts

export const HOST: string = "localhost";
export const PORT_SERVER: number = 3333;
export const PORT_FRONT: number = 3000;

export const EVENTNAME_CONNECTION: string = "connect";
export const EVENTNAME_DISCONNECT: string = "disconnect";
export const EVENTNAME_MESSAGE: string = "message";
export const EVENTNAME_BROADCAST: string = "broadcast";

5-2-2. src/common/definitions.ts

export type ChatData = {
  socketId?: string;
  msg: string;
};

5-2-3. src/socket.ts

"use client";

import { io } from "socket.io-client";
import { HOST, PORT_SERVER } from "./common/constants";

const URL_SERVER: string =
  process.env.SOCKET_URL || `http://${HOST}:${PORT_SERVER}`;

export const socket = io(URL_SERVER);

5-2-4. src/components/connection-manager.tsx

import { socket } from "../socket";

const ConnectionManager = () => {
  const join = () => {
    socket.connect();
  };

  const leave = () => {
    socket.disconnect();
  };

  return (
    <>
      <button onClick={join}>Join</button>
      <button onClick={leave}>Leave</button>
    </>
  );
};

export default ConnectionManager;

5-2-5. src/components/chats.tsx

import { ChatData } from "@/common/definitions";

type Props = {
  chats: ChatData[];
};

const Chats = (props: Props) => {
  return (
    <ul>
      {props.chats.map((chat: ChatData, index) => (
        <li key={index}>{`[${chat.socketId}] ${chat.msg}`}</li>
      ))}
    </ul>
  );
};

export default Chats;

5-2-6. src/components/chat-form.tsx

import { FormEvent, useState } from "react";
import { socket } from "../socket";
import { EVENTNAME_MESSAGE } from "@/common/constants";
import { ChatData } from "@/common/definitions";

const ChatForm = () => {
  const [msg, setMsg] = useState("");
  const [isLoading, setIsLoading] = useState(false);

  const sendMsg = (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    setIsLoading(true);

    const chatData: ChatData = {
      msg: msg,
    };
    socket.timeout(100).emit(EVENTNAME_MESSAGE, chatData, () => {
      setIsLoading(false);
      setMsg("");
    });
  };

  return (
    <form onSubmit={(e) => sendMsg(e)}>
      <input value={msg} onChange={(e) => setMsg(e.target.value)} />
      <button type="submit" disabled={isLoading}>
        Chat
      </button>
    </form>
  );
};

export default ChatForm;

5-2-7. src/app/page.tsx

"use client";

import styles from "./page.module.css";
import { useEffect, useState } from "react";
import { socket } from "../socket";
import Chats from "@/components/chats";
import ConnectionManager from "@/components/connection-manager";
import ChatForm from "@/components/chat-form";
import {
  EVENTNAME_CONNECTION,
  EVENTNAME_DISCONNECT,
  EVENTNAME_BROADCAST,
} from "@/common/constants";
import { ChatData } from "@/common/definitions";

const Home = () => {
  const [isConnected, setIsConnected] = useState(false);
  const [transport, setTransport] = useState("N/A");
  const [chatData, setChatData] = useState<ChatData[]>([]);

  useEffect(() => {
    const onConnect = () => {
      setIsConnected(true);
      setTransport(socket.io.engine.transport.name);

      socket.io.engine.on("upgrade", (transport) => {
        setTransport(transport.name);
      });
    };

    const onDisconnect = () => {
      setIsConnected(false);
      setTransport("N/A");
      setChatData([]);
    };

    if (socket.connected) {
      onConnect();
    }

    socket.on(EVENTNAME_CONNECTION, onConnect);
    socket.on(EVENTNAME_DISCONNECT, onDisconnect);

    return () => {
      socket.off(EVENTNAME_CONNECTION, onConnect);
      socket.off(EVENTNAME_DISCONNECT, onDisconnect);
    };
  }, []);

  useEffect(() => {
    socket.on(EVENTNAME_BROADCAST, (chat: ChatData) => {
      setChatData([...chatData, chat]);
    });

    return () => {
      socket.off(EVENTNAME_BROADCAST);
    };
  }, [chatData]);

  return (
    <div className={styles.page}>
      <main className={styles.main}>
        <p>Status: {isConnected ? "joined" : "leaved"}</p>
        <p>Transport: {transport}</p>
        <hr />
        <Chats chats={chatData} />
        <ConnectionManager />
        <ChatForm />
      </main>
    </div>
  );
};

export default Home;

5-3. サーバーサイト

5-3-1. server/server-socket.ts

import { RequestHandler } from "next/dist/server/next";
import { createServer } from "node:http";
import { Server, Socket } from "socket.io";
import {
  HOST,
  PORT_FRONT,
  EVENTNAME_CONNECTION,
  EVENTNAME_DISCONNECT,
  EVENTNAME_MESSAGE,
  EVENTNAME_BROADCAST,
} from "../src/common/constants";
import { ChatData } from "../src/common/definitions";

type Props = {
  handler: RequestHandler;
};

const server = (props: Props) => {
  const httpServer = createServer(props.handler);
  const io = new Server(httpServer, {
    cors: {
      origin: `http://${HOST}:${PORT_FRONT}`,
      methods: ["GET", "POST"],
    },
  });

  io.on(EVENTNAME_CONNECTION, (socket: Socket) => {
    const data: ChatData = { socketId: socket.id, msg: "joined." };
    io.emit(EVENTNAME_BROADCAST, data);

    socket.on(EVENTNAME_MESSAGE, (chatData: ChatData) => {
      const data: ChatData = {
        socketId: socket.id,
        msg: chatData.msg,
      };
      io.emit(EVENTNAME_BROADCAST, data);
    });

    socket.on(EVENTNAME_DISCONNECT, () => {
      const data: ChatData = {
        socketId: socket.id,
        msg: "leaved.",
      };
      io.emit(EVENTNAME_BROADCAST, data);
    });
  });

  return httpServer;
};

export default server;

5-3-2. server/app.ts

import next from "next";
import { NextServer } from "next/dist/server/next";
import server from "./server-socket";
import { HOST, PORT_SERVER } from "../src/common/constants";

const dev: boolean = process.env.NODE_ENV !== "production";
const hostname: string = process.env.HOSTNAME || HOST;
const port: number = parseInt(process.env.PORT || String(PORT_SERVER), 10);

const app: NextServer = next({ dev, hostname, port });
const handler = app.getRequestHandler();

app.prepare().then(() => {
  const httpServer = server({ handler });

  httpServer
    .once("error", (err) => {
      console.error(err);
      process.exit(1);
    })
    .listen(port, () => {
      console.log(
        `> Ready on http://${hostname}:${port} as ${
          dev ? "development" : process.env.NODE_ENV
        }`
      );
    });
});

6. サーバーを起動

6-1. サーバーサイド

npm run server:dev
> socket-sample@0.1.0 server:dev
> nodemon server/app.ts

[nodemon] 3.1.7
[nodemon] to restart at any time, enter `rs`
[nodemon] watching path(s): server/**/*
[nodemon] watching extensions: ts
[nodemon] starting `ts-node server/app.ts`
> Ready on http://localhost:3333 as development

6-2. フロントサイド

npm run dev
> socket-sample@0.1.0 dev
> next dev

   ▲ Next.js 15.0.2
   - Local:        http://localhost:3000

 ✓ Starting...
 ✓ Ready in 2.4s

7. ブラウザで確認

  • http://localhost:3000
    • ブラウザを3つ立ち上げて確認

8. ディレクトリの構造

.
├── README.md
├── next-env.d.ts
├── next.config.ts
├── nodemon.json
├── package-lock.json
├── package.json
├── public
│   ├── file.svg
│   ├── globe.svg
│   ├── next.svg
│   ├── vercel.svg
│   └── window.svg
├── server
│   ├── app.ts
│   └── server-socket.ts
├── src
│   ├── app
│   │   ├── favicon.ico
│   │   ├── fonts
│   │   │   ├── GeistMonoVF.woff
│   │   │   └── GeistVF.woff
│   │   ├── globals.css
│   │   ├── layout.tsx
│   │   ├── page.module.css
│   │   └── page.tsx
│   ├── common
│   │   ├── constants.ts
│   │   └── definitions.ts
│   ├── components
│   │   ├── chat-form.tsx
│   │   ├── chats.tsx
│   │   └── connection-manager.tsx
│   └── socket.ts
└── tsconfig.json

7 directories, 27 files

9. 備考

今回はSocketIOを使い、シンプルなチャットアプリを作る内容についてでした。

10. 参考

投稿者プロフィール

Sondon
開発好きなシステムエンジニアです。
卓球にハマってます。

関連記事

  1. 【NextJS】Pankoにオンラインビンゴが追加されました

  2. 【NextJS】OAuth authentication with A…

  3. 【NextJS】Access AppSync API with Dyn…

  4. 【NextJS】Zoom in on an image during …

  5. 【NextJS】Metadata(Head,Title)・Script…

  6. 【NextJS】Upload and Render Images wi…

最近の記事

  1. 日記アプリ第5回
  2. 日記アプリ第4回
  3. React
  4. 日記アプリ第3回
  5. 日記アプリ第2回
  6. 日記アプリ第1回

制作実績一覧

  1. Vivaya
  2. Checkeys