DEV Community

Virender Chauhan
Virender Chauhan

Posted on

Building Real-Time Messaging in a Next.js SaaS with Server-Sent Events

I recently shipped Do Rent, a property management SaaS with landlord, tenant, and admin roles. One requirement stood out early: landlords and tenants needed to message each other in real time, without a full chat infrastructure or a third-party service.

Here's how I built it with Server-Sent Events (SSE) instead of WebSockets, and what I'd do differently next time.

Why SSE over WebSockets

For Do Rent, messages only flow one direction that matters for "real-time" — server to client. The client still sends messages via a normal POST request; the server just needs to push new messages down to whoever's conversation is open. That's exactly the shape SSE is built for:

  • No extra library, no socket server to manage — it's a long-lived HTTP response.
  • Auto-reconnect is built into the browser's EventSource API.
  • Works cleanly with Next.js API routes and Vercel's serverless model, where a persistent WebSocket server is awkward to run.

WebSockets would have been overkill for a one-directional push, and added a whole reconnection/heartbeat protocol I'd have had to write myself.

The shape of it

On the server, an API route stays open and streams events as new messages are saved:

export async function GET(req: Request) {
  const stream = new ReadableStream({
    start(controller) {
      const send = (data: unknown) => {
        controller.enqueue(`data: ${JSON.stringify(data)}\n\n`);
      };
      // subscribe to new messages for this conversation
      // and call send() whenever one arrives
    },
  });

  return new Response(stream, {
    headers: {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache",
      Connection: "keep-alive",
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

On the client, EventSource subscribes and appends incoming messages to the conversation state:

const es = new EventSource(`/api/conversations/${id}/stream`);
es.onmessage = (event) => {
  const message = JSON.parse(event.data);
  setMessages((prev) => [...prev, message]);
};
Enter fullscreen mode Exit fullscreen mode

Data lives in MongoDB via Prisma, and every write (a new message, a status change) is what triggers the stream to push. Auth and role checks (landlord vs. tenant vs. admin) happen before the stream ever opens, so a user only ever gets events for conversations they're actually part of. Request/response bodies going into the REST APIs are validated with Zod, so a malformed payload never reaches the database layer in the first place.

What I'd do differently

SSE is a one-way street, which was fine here but won't be if Do Rent ever needs typing indicators or read receipts — those need the client to push small events too, which means falling back to regular POST calls alongside the SSE stream rather than a single connection. It works, but it's not as clean as a bidirectional protocol would be.

The other thing I underestimated: serverless functions and long-lived connections don't always mix well. On Vercel specifically, you have to be deliberate about timeouts and about closing the stream when a client disconnects, or you end up with dangling connections. Worth reading your host's docs on this before committing to SSE for anything at scale.

Repo: github.com/hey-virender/do-rent
Live: do-rent.vercel.app

Happy to answer questions if you're weighing SSE vs. WebSockets vs. polling for something similar.

Top comments (0)