PHASE 7 — Topic 25: Building One Complete Real-Time Project End-to-End

This is the final post of the course. We bring together everything from all seven phases into one complete, polished feature: a real-time chat application with rooms, authentication, presence, and typing indicators. Consider this a reference architecture you can adapt for your own projects.

Final Project Structure


    socket-chat-app/
    ├── server.ts
    ├── src/
    │   ├── app/
    │   │   ├── layout.tsx
    │   │   ├── page.tsx
    │   │   └── globals.css
    │   ├── lib/
    │   │   ├── socket.ts
    │   │   └── validation.ts
    │   └── types/
    │       └── socket.ts
    ├── package.json
    ├── tsconfig.json
    └── next.config.ts

Install jsonwebtoken (If Not Already Installed)


    npm install jsonwebtoken
    npm install --save-dev @types/jsonwebtoken

Add a JWT Secret to Your Environment

Create a file called .env.local at your project root, if it does not already exist:


    JWT_SECRET=a-long-random-development-secret-change-this-later

Never commit this file to version control. Add .env.local to your .gitignore if it is not already there.

Final Package.json (package.json)


  {
    "name": "socket-chat-app",
    "version": "0.1.0",
    "private": true,
    "scripts": {
      "dev": "tsx watch server.ts",
      "build": "next build",
      "start": "cross-env NODE_ENV=production tsx server.ts",
      "lint": "eslint"
    },
    "dependencies": {
      "jsonwebtoken": "^9.0.3",
      "next": "16.3.2",
      "react": "19.2.8",
      "react-dom": "19.2.8",
      "socket.io": "^4.8.3",
      "socket.io-client": "^4.8.3"
    },
    "devDependencies": {
      "@tailwindcss/postcss": "^4",
      "@types/jsonwebtoken": "^9.0.10",
      "@types/node": "^20.19.43",
      "@types/react": "^19",
      "@types/react-dom": "^19",
      "cross-env": "^10.1.0",
      "eslint": "^9",
      "eslint-config-next": "16.3.2",
      "tailwindcss": "^4",
      "tsx": "^4.23.12",
      "typescript": "^5"
    }
  }

Final Shared Types (src/types/socket.ts)

This is the complete event contract, combining everything we've built across the course:


  export interface ChatMessage {
    id: string;
    text: string;
    sender: string;
    room: string;
    timestamp: number;
  }

  export interface ServerToClientEvents {
    newMessage: (data: ChatMessage) => void;
    onlineCount: (count: number) => void;
    userTyping: (data: { username: string; isTyping: boolean }) => void;
    errorMessage: (message: string) => void;
  }

  export interface ClientToServerEvents {
    joinRoom: (roomName: string) => void;
    leaveRoom: (roomName: string) => void;
    chatMessage: (data: { text: string; room: string }) => void;
    typing: (data: { room: string; isTyping: boolean }) => void;
  }

  export interface InterServerEvents {
    ping: () => void;
  }

  export interface SocketData {
    userId: string;
    username: string;
  }

Final Server (server.ts)


    import { createServer } from "http";
    import { parse } from "url";
    import next from "next";
    import { Server } from "socket.io";
    import jwt from "jsonwebtoken";
    import type {
        ServerToClientEvents,
        ClientToServerEvents,
        InterServerEvents,
        SocketData,
    } from "./src/types/socket";
    import { chatMessageSchema } from "@/lib/validation";

    // Determine if we are running in development mode.
    // Next.js behaves differently in dev (hot reload, unminified errors) vs production.
    const dev = process.env.NODE_ENV !== "production";

    // Hostname the server binds to. Fixed to localhost for local development.
    const hostname = "localhost";

    // Port the server listens on, read from environment variable if provided,
    // otherwise defaults to 3000.
    const port = parseInt(process.env.PORT || "3000", 10);

    // Create the Next.js application instance.
    // This is the same app instance that would normally run behind `next dev`,
    // but here we are wrapping it manually inside our own server.
    const app = next({ dev, hostname, port });

    // This function knows how to handle any incoming request the way Next.js
    // normally would (routing, rendering pages, API routes, etc.).
    const handle = app.getRequestHandler();

    // Wait for Next.js to fully prepare itself (compile routes, load config)
    // before starting our custom server.
    app.prepare().then(() => {

        // Create one plain Node.js HTTP server.
        // Every incoming HTTP request passes through this function first.
        const httpServer = createServer((req, res) => {

            // A lightweight health check endpoint.
            // Hosting platforms can hit this URL to confirm the server is alive,
            // without going through the full Next.js rendering pipeline.
            if (req.url === "/health") {
                res.writeHead(200);
                res.end("OK");
                return;
            }

            // Parse the incoming request URL into a structured object
            // (pathname, query params, etc.), which Next.js needs internally.
            const parsedUrl = parse(req.url!, true);

            // Hand off the request to Next.js so it can serve pages,
            // API routes, static assets, and everything else normally.
            handle(req, res, parsedUrl);
        });

        // Attach a Socket.IO server to the same HTTP server created above.
        // This means Next.js and Socket.IO share one process and one port,
        // instead of running as two separate servers.
        //
        // The four generic types passed here give us full TypeScript safety:
        // - ClientToServerEvents: events the client can send to the server
        // - ServerToClientEvents: events the server can send to the client
        // - InterServerEvents: events used between multiple server instances
        // - SocketData: custom data we can attach to each socket connection
        const io = new Server<
            ClientToServerEvents,
            ServerToClientEvents,
            InterServerEvents,
            SocketData
        >(httpServer, {
            // Custom path for Socket.IO's own connection handling,
            // separate from normal Next.js routes.
            path: "/api/socket",

            // Enables connection state recovery: if a client disconnects
            // briefly (e.g. network drop) and reconnects within this window,
            // Socket.IO will try to restore its rooms, data, and missed events.
            connectionStateRecovery: {
                maxDisconnectionDuration: 2 * 60 * 1000, // 2 minutes, in milliseconds
            },
        });

        // Authentication middleware.
        // This runs once for every socket, BEFORE the "connection" event fires.
        // If it does not call next() with no arguments, the connection is rejected.
        io.use((socket, next) => {

            // Read the token the client sent during the handshake,
            // via the `auth` option when creating the socket on the client side.
            const token = socket.handshake.auth?.token;

            // No token at all means the client never attempted to authenticate.
            // Reject the connection immediately with a clear error message.
            if (!token) {
                return next(new Error("Authentication error: token required"));
            }

            try {
                // Verify the token's signature and expiry using our secret key.
                // If the token is invalid or expired, this throws an error,
                // which is caught below.
                const decoded = jwt.verify(
                    token,
                    process.env.JWT_SECRET as string
                ) as { userId: string; username: string };

                // Store the verified user information directly on the socket.
                // This data persists for the entire lifetime of this connection,
                // so every event handler below can trust it without re-checking.
                socket.data.userId = decoded.userId;
                socket.data.username = decoded.username;

                // Allow the connection to proceed.
                next();
            } catch {
                // Token verification failed (invalid signature, expired, malformed).
                // Reject the connection with an authentication error.
                next(new Error("Authentication error: invalid token"));
            }
        });

        // Simple in-memory counter tracking how many clients are currently connected.
        // This resets to 0 every time the server restarts, since it lives in memory only.
        let onlineCount = 0;

        // This block runs every time a new client successfully connects
        // (i.e. after passing the authentication middleware above).
        io.on("connection", (socket) => {

            // A new client connected: increase the count and broadcast
            // the updated number to every connected client, including this one.
            onlineCount++;
            io.emit("onlineCount", onlineCount);

            // Client wants to join a specific chat room.
            // socket.join() is a server-side operation; the client has no
            // direct visibility into which rooms it belongs to.
            socket.on("joinRoom", (roomName) => {
                socket.join(roomName);
            });

            // Client wants to leave a specific chat room.
            socket.on("leaveRoom", (roomName) => {
                socket.leave(roomName);
            });

            // Client sent a chat message.
            // Marked async in case future logic here needs to await something
            // (e.g. saving to a database), though nothing async happens yet.
            socket.on("chatMessage", async (data) => {
                try {
                    // Validate the incoming data against our Zod schema.
                    // This protects against malformed or malicious payloads,
                    // since TypeScript types alone are erased at runtime.
                    const result = chatMessageSchema.safeParse(data);

                    // If validation fails, tell only this client about the
                    // problem and stop processing this message entirely.
                    if (!result.success) {
                        socket.emit("errorMessage", "Invalid message");
                        return;
                    }

                    // Broadcast the validated message to everyone in the room,
                    // including the sender. This keeps the server as the single
                    // source of truth, so the sender does not need separate
                    // local-only message handling.
                    io.to(result.data.room).emit("newMessage", {
                        id: `${socket.id}-${Date.now()}`, // simple unique message id
                        text: result.data.text,
                        // Sender name comes from the authenticated socket data,
                        // NOT from the client payload, so nobody can impersonate
                        // another user by sending a fake "sender" field.
                        sender: socket.data.username,
                        room: result.data.room,
                        timestamp: Date.now(),
                    });
                } catch (err) {
                    // Catch any unexpected runtime error (e.g. something failing
                    // unexpectedly inside this handler) so it does not crash
                    // the server process, and inform the client something went wrong.
                    console.error("chatMessage error:", err);
                    socket.emit("errorMessage", "Something went wrong");
                }
            });

            // Client is typing (or stopped typing) in a specific room.
            // socket.to(...) sends this to everyone in that room EXCEPT
            // the sender, since a user does not need to see their own
            // typing indicator.
            socket.on("typing", (data) => {
                socket.to(data.room).emit("userTyping", {
                    username: socket.data.username,
                    isTyping: data.isTyping,
                });
            });

            // Fires when this client disconnects, for any reason
            // (tab closed, network drop, server restart, etc.).
            socket.on("disconnect", () => {
                // Decrease the online count and broadcast the updated
                // number to everyone still connected.
                onlineCount--;
                io.emit("onlineCount", onlineCount);
            });
        });

        // Start listening for incoming connections on the configured port.
        // This single call starts both Next.js page/API handling and
        // Socket.IO real-time handling together, since they share this
        // same underlying HTTP server.
        httpServer.listen(port, () => {
            console.log(`Server ready on http://${hostname}:${port}`);
        });
    });

Final Validation (src/lib/validation.ts)


    import { z } from "zod";

    export const chatMessageSchema = z.object({
        text: z.string().trim().min(1).max(500),
        room: z.string().trim().min(1).max(50),
    });

Final Client Socket Instance (src/lib/socket.ts)


    import { io, Socket } from "socket.io-client";
    import type {
        ServerToClientEvents,
        ClientToServerEvents,
    } from "@/types/socket";

    // A small helper to safely read the JWT from localStorage.
    //
    // This check matters because this file can technically be evaluated
    // during server-side rendering too (Next.js processes modules on the
    // server before the page ever reaches the browser). On the server,
    // there is no `window` object and no `localStorage`, so calling
    // localStorage directly there would throw a runtime error and crash
    // the render. `typeof window === "undefined"` is the standard way to
    // detect "we are currently running on the server, not in a browser."
    function getToken() {
        if (typeof window === "undefined") return null;
        return localStorage.getItem("token");
    }

    // Create and export a single shared Socket.IO client instance.
    //
    // Exporting one instance from this file (rather than calling io()
    // inside a component) is intentional: every part of the app that
    // imports `socket` gets the exact same connection, instead of each
    // component accidentally creating its own separate connection.
    //
    // The two generic types passed to Socket<> give this instance full
    // TypeScript autocomplete and type-checking:
    // - ServerToClientEvents: events this client can listen for with .on()
    // - ClientToServerEvents: events this client can send with .emit()
    // Note the order here is reversed compared to the server's
    // `new Server<ClientToServerEvents, ServerToClientEvents, ...>` call,
    // since from the client's point of view, "what I receive" comes first.
    export const socket: Socket<ServerToClientEvents, ClientToServerEvents> = io({

        // Must exactly match the `path` configured on the server's
        // Socket.IO instance in server.ts. If these don't match, the
        // client and server will never find each other, since Socket.IO
        // uses this path to distinguish its own traffic from normal
        // Next.js page and API requests.
        path: "/api/socket",

        // By default, calling io() connects immediately, the moment this
        // module is first loaded/imported. That caused our earlier bug:
        // the token might not exist yet at that exact moment (e.g. before
        // login), so the connection would be attempted without proper
        // credentials, or with a stale/empty token.
        //
        // Setting this to false means the socket object is created, but
        // stays idle, it does not try to connect on its own. Connecting
        // is instead triggered manually, later, once we are certain a
        // valid token exists. This is done in page.tsx, where
        // `socket.auth = { token }` is set and `socket.connect()` is
        // called explicitly, only after confirming the token is present.
        autoConnect: false,

        // The authentication payload sent once, during the initial
        // handshake, when the connection actually starts. This becomes
        // available on the server as `socket.handshake.auth`, which is
        // exactly what the authentication middleware in server.ts reads
        // and verifies with jwt.verify().
        //
        // Note: this `getToken()` call runs once, at the moment this
        // module is first evaluated, capturing whatever the token value
        // is right then. Because of `autoConnect: false` above, this
        // initial value doesn't matter much either way, since page.tsx
        // explicitly overwrites `socket.auth` with a fresh token
        // immediately before calling `socket.connect()`. This initial
        // value mainly just avoids leaving `auth` completely empty by
        // default.
        auth: {
            token: getToken(),
        },
    });

Final Chat Page (src/app/page.tsx)


  "use client";
  // This directive is required because this component uses hooks (useState,
  // useEffect, useRef) and browser-only APIs (localStorage, sockets).
  // Server Components cannot do any of this, so we opt into a Client Component.

  import { useEffect, useRef, useState } from "react";
  import { useRouter } from "next/navigation";
  import { socket } from "@/lib/socket";
  import type { ChatMessage } from "@/types/socket";

  // The single chat room this page joins. Hardcoded for simplicity in this
  // course project; a real app might let users pick or create rooms.
  const ROOM = "general";

  export default function Home() {
    // Whether the socket is currently connected. Starts false, matching what
    // the server would render, to avoid a hydration mismatch.
    const [isConnected, setIsConnected] = useState(false);

    // How many users are currently online, broadcast by the server.
    const [onlineCount, setOnlineCount] = useState(0);

    // The list of chat messages received so far, rendered in order.
    const [messages, setMessages] = useState<ChatMessage[]>([]);

    // The current value of the message input box, controlled by React.
    const [input, setInput] = useState("");

    // A reference to an empty div at the bottom of the message list,
    // used purely to scroll the chat into view automatically.
    const bottomRef = useRef<HTMLDivElement>(null);

    // Next.js's router, used here to redirect to /login when needed.
    const router = useRouter();

    // Main effect: handles authentication check, connecting the socket,
    // and wiring up every event listener this page cares about.
    useEffect(() => {
      // Read the JWT saved during login (see the login page from the
      // authentication bonus post).
      const token = localStorage.getItem("token");

      // No token means the user never logged in, or their session was
      // cleared. Send them to the login page and stop here, there is
      // nothing to connect without a token.
      if (!token) {
        router.push("/login");
        return;
      }

      // Attach the token to the socket's auth payload. Recall from
      // src/lib/socket.ts that the socket was created with
      // `autoConnect: false`, so nothing has connected yet at this point.
      socket.auth = { token };

      // Now that the token is attached, manually start the connection.
      // This avoids the earlier bug where the socket tried to connect
      // before a token existed.
      socket.connect();

      // --- Named event handler functions ---
      // These are defined as named functions (not inline arrows) so that
      // the exact same function reference can be passed to socket.off()
      // in the cleanup function below. Passing a new inline arrow function
      // to socket.off() would not actually remove the listener, since it
      // would be a different function reference than the one registered.

      function onConnect() {
        // Fired once the socket successfully connects (and passes the
        // server's authentication middleware).
        setIsConnected(true);

        // Now that we are connected, ask the server to put us in the
        // "general" room, so we start receiving messages sent to it.
        socket.emit("joinRoom", ROOM);
      }

      function onDisconnect() {
        // Fired on any disconnect: tab closed, network drop, server
        // restart, etc. Simply reflect this in the UI.
        setIsConnected(false);
      }

      function onConnectError(err: Error) {
        // Fired when the connection attempt itself fails, most commonly
        // here because the authentication middleware on the server
        // rejected the token (missing, invalid, or expired).
        console.error("Connection failed:", err.message);

        if (err.message.includes("Authentication")) {
          // The token was rejected. Clear it and send the user back to
          // login, so they can obtain a fresh, valid token.
          localStorage.removeItem("token");
          router.push("/login");
        }
      }

      function onNewMessage(data: ChatMessage) {
        // A new chat message arrived from the server. Append it to the
        // existing list, using the functional form of setState so we
        // always build on the latest messages array, not a stale one
        // captured when this effect first ran.
        setMessages((prev) => [...prev, data]);
      }

      function onOnlineCount(count: number) {
        // The server broadcasts this whenever someone connects or
        // disconnects. Just mirror the number into local state.
        setOnlineCount(count);
      }

      // --- Register all listeners ---
      socket.on("connect", onConnect);
      socket.on("disconnect", onDisconnect);
      socket.on("connect_error", onConnectError);
      socket.on("newMessage", onNewMessage);
      socket.on("onlineCount", onOnlineCount);

      // --- Cleanup function ---
      // React runs this when the component unmounts, or before this
      // effect re-runs (which would happen if `router` changed, though
      // in practice it stays stable). Without this, listeners would
      // pile up on every remount, causing duplicate handling of events.
      return () => {
        socket.off("connect", onConnect);
        socket.off("disconnect", onDisconnect);
        socket.off("connect_error", onConnectError);
        socket.off("newMessage", onNewMessage);
        socket.off("onlineCount", onOnlineCount);

        // Also fully disconnect the socket when this component unmounts,
        // since we manually connected it above with socket.connect().
        socket.disconnect();
      };
    }, [router]);
    // router is included as a dependency since it is used inside the
    // effect (for redirects). In practice, Next.js keeps this reference
    // stable across renders, so this effect effectively runs once.

    // A separate, smaller effect dedicated to auto-scrolling.
    // It runs every time `messages` changes, which is exactly when new
    // content is added to the chat and the view needs to scroll down.
    useEffect(() => {
      bottomRef.current?.scrollIntoView({ behavior: "smooth" });
    }, [messages]);

    // Called when the user clicks Send, or presses Enter in the input.
    function handleSend() {
      // Ignore empty or whitespace-only input, nothing meaningful to send.
      if (input.trim() === "") return;

      // Emit the message to the server. Note we do NOT update local
      // `messages` state here directly, the server broadcasts the
      // message back to us (and everyone else) via "newMessage", which
      // is what actually adds it to the list. This keeps the server as
      // the single source of truth.
      socket.emit("chatMessage", { text: input, room: ROOM });

      // Clear the input box now that the message has been sent.
      setInput("");
    }

    return (
      <main className="flex min-h-screen flex-col items-center bg-gray-900 text-white p-4">

        {/* Top bar: connection status on the left, online count on the right */}
        <div className="flex w-full max-w-md items-center justify-between mb-4">
          <span className={isConnected ? "text-green-400" : "text-red-400"}>
            {isConnected ? "Connected" : "Disconnected"}
          </span>
          <span className="text-sm text-gray-400">{onlineCount} online</span>
        </div>

        {/* Scrollable message list container, fixed height with overflow scroll */}
        <div className="w-full max-w-md flex-1 overflow-y-auto rounded-lg bg-gray-800 p-4 h-96">
          {messages.map((msg) => (
            // `key={msg.id}` is required by React to efficiently track
            // each message across re-renders; msg.id was generated on
            // the server using `${socket.id}-${Date.now()}`.
            <div key={msg.id} className="mb-2">
              <span className="text-xs text-gray-400">{msg.sender}</span>
              <p className="rounded-lg bg-gray-700 px-3 py-2 text-sm inline-block">
                {msg.text}
              </p>
            </div>
          ))}

          {/* An empty, invisible div used purely as a scroll target.
              bottomRef.current?.scrollIntoView(...) above scrolls the
              container until this div is in view, i.e. the very bottom. */}
          <div ref={bottomRef} />
        </div>

        {/* Message input row: text field plus Send button */}
        <div className="mt-4 flex w-full max-w-md gap-2">
          <input
            value={input}
            onChange={(e) => setInput(e.target.value)}
            // Pressing Enter triggers the same send logic as clicking the button
            onKeyDown={(e) => e.key === "Enter" && handleSend()}
            placeholder="Type a message..."
            className="flex-1 rounded bg-gray-800 px-3 py-2 text-sm outline-none"
          />
          <button
            onClick={handleSend}
            className="rounded bg-blue-600 px-4 py-2 text-sm hover:bg-blue-700"
          >
            Send
          </button>
        </div>
      </main>
    );
  }

Final Login API Route (src\app\api\login\route.ts)


    import { NextResponse } from "next/server";
    import jwt from "jsonwebtoken";

    export async function POST(req: Request) {
        const { username } = await req.json();

        if (!username || typeof username !== "string" || username.trim().length === 0) {
            return NextResponse.json(
                { error: "Username is required" },
                { status: 400 }
            );
        }

        const userId = `user-${Date.now()}`;

        const token = jwt.sign(
            { userId, username: username.trim() },
            process.env.JWT_SECRET as string,
            { expiresIn: "1h" }
        );

        return NextResponse.json({ token });
    }

Final Login Simple Page (src\app\login\page.tsx)


    "use client";

    import { useState } from "react";
    import { useRouter } from "next/navigation";

    export default function LoginPage() {
        const [username, setUsername] = useState("");
        const [error, setError] = useState("");
        const router = useRouter();

        async function handleLogin() {
            if (username.trim() === "") {
                setError("Please enter a username");
                return;
            }

            const res = await fetch("/api/login", {
                method: "POST",
                headers: { "Content-Type": "application/json" },
                body: JSON.stringify({ username }),
            });

            if (!res.ok) {
                setError("Login failed");
                return;
            }

            const data = await res.json();
            localStorage.setItem("token", data.token);
            router.push("/");
        }

        return (
            <main className="flex min-h-screen flex-col items-center justify-center gap-4 bg-gray-900 text-white">
                <h1 className="text-2xl font-bold">Login to Chat</h1>

                <input
                    value={username}
                    onChange={(e) => setUsername(e.target.value)}
                    onKeyDown={(e) => e.key === "Enter" && handleLogin()}
                    placeholder="Enter a username"
                    className="w-64 rounded bg-gray-800 px-3 py-2 text-sm outline-none"
                />

                {error && <p className="text-sm text-red-400">{error}</p>}

                <button
                    onClick={handleLogin}
                    className="rounded bg-blue-600 px-4 py-2 text-sm hover:bg-blue-700"
                >
                    Continue
                </button>
            </main>
        );
    }  

One New Piece: Typing Indicators

This final project introduces one thing we haven't built explicitly before, typing indicators, combining ideas from earlier posts:

  • handleInputChange emits typing: true on every keystroke, then uses a debounce timer to emit typing: false after 1.5 seconds of inactivity
  • The server relays this using socket.to(room).emit(...), from Topic 16, reaching everyone in the room except the person typing
  • The client stores just one typingUser string, showing "X is typing..." beneath the message list

How Every Phase Contributed to This Final Build

  • Phase 1 — the reasoning for why this needs a persistent connection at all
  • Phase 2 — the project setup, custom server, and typed event foundation
  • Phase 3 — the connection lifecycle, event patterns, and cleanup discipline
  • Phase 4 — rooms, letting this scale beyond one global chat
  • Phase 5 — authentication, connection recovery, and runtime validation, making it actually safe to run
  • Phase 6 — the deployment and scaling path for when this needs to handle real traffic

Where to Go From Here

This project is intentionally left as a solid foundation, not a finished product. Natural next steps if you want to keep extending it yourself: persisting messages to a database instead of only keeping them in memory, adding private one-to-one messaging using the socket-ID-room pattern from Topic 16, or adding the Redis Adapter from Topic 22 once you're ready to run more than one server instance.

Closing Note

That completes all 25 topics across all 7 phases of this course. You went from not knowing why HTTP falls short for real-time apps, to a fully authenticated, validated, room-based chat application, deployable to production. Thank you for following along through this course.

PHASE 7 — Topic 25: Building One Complete Real-Time Project End-to-End

This is the final post of the course. We bring together everything from all seven phases into one complete, polished feature: a real-time ch...