【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】Hooks-useContext

  3. 【NextJS】OAuth authentication with G…

  4. 【NextJS】Hooks-useState(update toget…

  5. 【NextJS】Server Actions with MySQL

  6. 【NextJS】Checkbox・Radio Group

最近の記事

  1. raspberrypi

制作実績一覧

  1. Checkeys