Using WebSockets with Next.js on Fly.io

Two cartoon birds relaxing and chatting over the phone
Image by Annie Sexton

We’re Fly.io. We make hard things easy, including deploying your apps around the world, close to your users. This article illustrates how to use WebSockets in your Next.js app without the need for third-party services. If you’d like to learn more about deploying Next.js apps on Fly.io, check out our docs here.

In this article I’ll show you how to build a real-time chat app with WebSocket in a Next.js app. This solution works whether you’re using the App Router or the Pages Router, as much of the magic comes from setting up a custom Next.js server. Let’s dive in!

Demystifying WebSockets

To understand WebSockets, we should first compare them with something we’re more familiar with: HTTP requests. In an HTTP request, a message is sent to the server. The server Does A Thing, and then sends back a response. Once the request-response cycle has been complete, the connection is closed. This is an oversimplifictation, but the pattern of request followed by a response is the key point.

WebSockets work a bit differently, because the connection between the client and server is held open until one of the parties closes it. The difference between these two protocols is a bit like having a conversation over walkie-talkie (HTTP requests) vs talking on the telephone (WebSockets). With walkie-talkies, one person talks at a time, and thus the line is only ever open in one direction at a time. When talking over the phone, both parties can speak at anytime, and the connection remains open until someone hangs up.

All WebSocket connections start out as normal HTTP requests with a special header. This header informs the server, “Hey, FYI, I should be treated as a WebSocket connection; please keep the door open.” Its then up to the server to “upgrade” the connection to WebSockets, and from there, the client and server are free to send information back and forth at will.

The Challenge of WebSockets on Next.js

If you’re used to deploying your Next.js apps on serverless architecture, WebSockets don’t work out of the box. Serverless functions are built to be spun up and spun down as fast as possible and don’t accommodate long-running connections. It’s possible to use third-party services to add WebSocket support, but that isn’t your only option.

When deploying your app on Fly.io, your app runs as a traditional server, and thus WebSocket implementation is very straightforward; no third-party tools necessary. Additionally, Fly Machines are actually just fast-booting micro virtual machines that are managed by Firecracker (the same virtualization engine behind AWS Lambda). And unlike traditional servers that stay running, even when not in use, Fly Machines can be configured to automatically start and stop based on demand. Yay for only paying for what you use!

Building a Next.js App with WebSockets

We’re going to build a very simple chat application that uses WebSocket to allow multiple clients to send and receive messages in realtime. Since the purpose is just to illustrate how one could use WebSockets with Next.js, we’ll be making this dead simple: no authentication, no users, just an input where you can send a message for all clients to see.

Now, as we learned before, WebSockets need to be upgraded on the server to maintain the connection, and so we’ll be building a custom server for Next.js.

Let’s start out with a fresh Next.js install. We won’t be needing any styling for this mini-project, so skip Tailwind for now.

npx create-next-app@latest

Next, let’s install the dependencies we’ll need for our custom server:

npm i express ws

Creating the custom server

At the root of your application create a file called server.js with the following content:

// server.js
const { parse } = require('url');
const express = require("express");
const next = require('next');
const WebSocket = require('ws');
const { WebSocketServer } = require('ws');

const app = express();
const server = app.listen(3000);
const wss = new WebSocketServer({ noServer: true });
const nextApp = next({ dev: process.env.NODE_ENV !== "production" });
const clients = new Set();

nextApp.prepare().then(() => {
  app.use((req, res, next) => {
    nextApp.getRequestHandler()(req, res, parse(req.url, true));
  });

  wss.on('connection', (ws) => {
    clients.add(ws);
    console.log('New client connected');

    ws.on('message', (message, isBinary) => {
      console.log(`Message received: ${message}`);
      clients.forEach(client => {
        if (client.readyState === WebSocket.OPEN && (message.toString() !== `{"event":"ping"}`)) {
          client.send(message, { binary: isBinary });
        }
      });
    });

    ws.on('close', () => {
      clients.delete(ws);
      console.log('Client disconnected');
    });
  });

  server.on("upgrade", (req, socket, head) => {
    const { pathname } = parse(req.url || "/", true);

    // Make sure we all for hot module reloading
    if (pathname === "/_next/webpack-hmr") {
      nextApp.getUpgradeHandler()(req, socket, head);
    }

    // Set the path we want to upgrade to WebSockets
    if (pathname === "/api/ws") {
      wss.handleUpgrade(req, socket, head, (ws) => {
        wss.emit('connection', ws, req);
      });
    }
  });
})

Let’s take a look at this section here, as this is what you’d want to change to tweak the app behavior when a new message arrives via a WebSocket channel:

ws.on('message', (message, isBinary) => {
  console.log(`Received message: ${message}`);
  clients.forEach(client => {
    if (client.readyState === WebSocket.OPEN && (message.toString() !== `{"event":"ping"}`)) {
      client.send(message, { binary: isBinary });
    }
  });
});

We’re building a realtime chat app, so the only thing we need our WebSocket server to do is send the message received back to each client, and that’s what we’re doing above.

Another significant section of our custom server is this:

if (pathname === "/api/ws") {
  wss.handleUpgrade(req, socket, head, (ws) => {
    wss.emit('connection', ws, req);
  });
}

This is the path we will use in our client code to initiate a WebSocket connection. It doesn’t matter what this route is, as we won’t actually be creating a file-based route in Next.js like you normally would. In fact, this is all the code we need to make this route usable.

Finally, in order to use our custom server, we’ll need to update our start command from next start (or next dev) to something else:

...
 "scripts": {
    ...
    "dev": "node server.js",
    "start": "node server.js"
    ...
  },
...

What about TypeScript? It’s more common to see custom servers written without TypeScript (using CommonJS), since otherwise you’d need to transpile the code first, or use something like ts-node. If you’re using a runtime that supports TypeScript out of the box, such as Deno or Bun, this is less of a concern.

Adding the client-side code

Now that we have our Next.js app running on a custom server that handles WebSocket requests, let’s finally build our chat app!

Replace the homepage of your app with the following code (it doesn’t matter if you’re using the Pages or the App router). I’ll be using the App router.

// app/page.tsx

// "use client" only necessary for App Router
"use client"
import { useState, useEffect } from 'react';

let webSocket: WebSocket;
if (typeof window !== "undefined") {
  const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";

  webSocket = new WebSocket(`${protocol}//${window.location.host}/api/ws`);
  setInterval(() => {
    if (webSocket.readyState !== webSocket.OPEN) {
      webSocket = new WebSocket(`${protocol}//${window.location.host}/api/ws`);
      return;
    }

    webSocket.send(`{"event":"ping"}`);
  }, 29000);
}

const Index = () => {

  const [messages, setMessages] = useState<string[]>([]);
  const [newMessage, setNewMessage] = useState('');

  useEffect(() => {
    webSocket.onmessage = (event) => {
      if (event.data === "connection established") return;
      setMessages((prevMessages) => [...prevMessages, event.data]);
    };
  }, []);

  const sendMessage = () => {
    webSocket.send(newMessage);
    setNewMessage('');
  };

  return (
    <div>
      <h1>Real-Time Chat</h1>
      <div>
        {messages.map((message, index) => (
          <div key={index}>{message}</div>
        ))}
      </div>
        <input
          type="text"
          className="border border-gray-400 rounded p-2"
          value={newMessage}
          onChange={(e) => setNewMessage(e.target.value)}
          placeholder="Type your message..."
        />
        <button onClick={sendMessage}>Send</button>
    </div>
  );
};

export default Index;

Let’s try running this code! Start your development server and then open http://localhost:3000 in two tabs. You should see the following:

Screenshot of chat app

If you type a message and click “Send”, you should not only see your message inserted above, but it should be visible in both tabs. This is because each tab creates its own WebSocket connection and our server is configured to send back the messages to all clients.

And there you have it! An extremely paired down chat application. Who needs Slack?

Deploying our chat app to Fly.io

As mentioned before, using WebSockets on Fly.io doesn’t require any special work, so let’s deploy our new chat app!

We can do this with one command, which will also generate a Dockerfile for us as well as a fly.toml:

fly launch --name <your-app-name>

And that’s it! If you run fly open you should see your live application at https://<your-app-name>.fly.dev

Conclusion

The documentation for using WebSockets on Next.js is a bit lacking, but that doesn’t mean it’s hard to implement. After this article, you now have a primer to help build more elaborate realtime Next.js services on Fly.io.