Published February 24, 2026

Adding SSE and WebSockets to a Self-Hosted Next.js Stack

A self-hosted Next.js app becomes much more interesting when it is not limited to normal request-response pages.

11 min read

Modern apps often need live feedback.

A user submits a long-running job and wants to see progress. An AI report is being generated in the background. A dashboard needs fresh updates without refreshing the page. A chat interface needs instant messages. A collaborative tool needs both sides to communicate continuously.

This is where realtime communication comes in.

When you self-host Next.js on your own infrastructure, you are not limited to the default behavior of a frontend platform. You can run long-lived backend processes, tune Nginx, keep connections open, and choose the right realtime pattern for each feature.

The two most useful options are:

SSE          → Server pushes updates to browser
WebSockets   → Browser and server talk both ways

Both can fit cleanly into a self-hosted stack.

Why realtime belongs in this architecture

A self-hosted Next.js stack is usually not just a frontend.

It often includes:

  • a backend API

  • background workers

  • Redis queues

  • database updates

  • AI processing

  • file imports

  • scraping jobs

  • webhook handling

  • scheduled tasks

Many of these actions happen outside the normal request-response cycle.

Example:

User submits request
  ↓
Backend creates job
  ↓
Worker processes job
  ↓
Worker updates progress
  ↓
Frontend shows progress live

Without realtime updates, the user experience becomes awkward.

You either show a spinner for a long time, ask users to refresh the page, or poll the backend every few seconds.

That works, but it can feel crude.

Realtime gives the user confidence that something is happening.

For AI products, this matters even more. If an AI report takes 45 seconds, the difference between a frozen loading screen and a live progress feed is huge.

Instead of:

Generating report...

You can show:

Fetching page content...
Analyzing landing page...
Finding competitors...
Comparing alternatives...
Preparing recommendations...
Report completed.

This makes the product feel faster, even if the actual processing time is the same.

Polling vs SSE vs WebSockets

Before choosing a realtime approach, it helps to separate the options clearly.

Polling

Polling means the browser asks the server for updates repeatedly.

Example:

GET /api/reports/123/status every 3 seconds

Polling is simple.

It works everywhere. It is easy to debug. It does not require long-lived connections. It is often good enough for early versions.

A basic polling loop:

setInterval(async () => {
  const res = await fetch("/api/reports/123/status");
  const data = await res.json();

  updateUI(data);
}, 3000);

Good for:

  • simple dashboards

  • early MVPs

  • low-frequency updates

  • job status checks

  • admin panels

Downsides:

  • wasteful if nothing changes

  • updates are delayed by polling interval

  • many users can create unnecessary API load

  • not ideal for rich realtime experiences

Polling is not bad. It is just basic.

Server-Sent Events

SSE lets the server keep an HTTP connection open and push events to the browser.

The browser opens a connection:

const events = new EventSource("/api/reports/123/events");

events.onmessage = (event) => {
  const data = JSON.parse(event.data);
  updateUI(data);
};

The server sends messages like:

data: {"stage":"analyzing","progress":40}

data: {"stage":"completed","progress":100}

Good for:

  • progress updates

  • AI generation status

  • logs

  • notifications

  • import status

  • one-way dashboard updates

SSE is usually easier than WebSockets because it uses normal HTTP semantics. The browser has built-in support through EventSource.

Downsides:

  • server-to-client only

  • not ideal for chat input or collaborative editing

  • long-lived connections need correct Nginx config

  • browser connection limits can matter if abused

For many SaaS products, SSE is the sweet spot.

WebSockets

WebSockets create a persistent two-way connection between browser and server.

The browser can send messages to the server, and the server can send messages back anytime.

Good for:

  • chat

  • collaborative apps

  • multiplayer features

  • live cursors

  • realtime dashboards with subscriptions

  • voice/audio streaming

  • interactive AI agents

  • bidirectional control flows

Example browser code:

const socket = new WebSocket("wss://api.example.com/ws");

socket.onmessage = (event) => {
  const data = JSON.parse(event.data);
  updateUI(data);
};

socket.onopen = () => {
  socket.send(JSON.stringify({ type: "subscribe", room: "report-123" }));
};

Downsides:

  • more operational complexity

  • harder to scale horizontally

  • needs connection lifecycle handling

  • needs reconnect logic

  • may require pub/sub when multiple servers are involved

Use WebSockets when you really need two-way realtime communication.

Do not use WebSockets just because they sound more advanced.

SSE for progress updates

SSE is ideal for background job progress.

Imagine a self-hosted app with this flow:

Next.js frontend
  ↓
Python API
  ↓
Redis queue
  ↓
RQ worker
  ↓
Database progress updates
  ↓
SSE stream to browser

The user submits a job:

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

const report = await res.json();

Then the frontend opens an SSE connection:

const source = new EventSource(`/api/reports/${report.id}/events`);

source.onmessage = (event) => {
  const update = JSON.parse(event.data);

  setStage(update.stage);
  setProgress(update.progress);

  if (update.status === "completed" || update.status === "failed") {
    source.close();
  }
};

The backend streams updates:

from flask import Response, stream_with_context
import json
import time

@app.route("/api/reports/<report_id>/events")
def report_events(report_id):
    def generate():
        last_stage = None

        while True:
            report = get_report(report_id)

            payload = {
                "status": report.status,
                "stage": report.stage,
                "progress": report.progress,
            }

            yield f"data: {json.dumps(payload)}\n\n"

            if report.status in ["completed", "failed"]:
                break

            time.sleep(2)

    return Response(
        stream_with_context(generate()),
        mimetype="text/event-stream",
        headers={
            "Cache-Control": "no-cache",
            "X-Accel-Buffering": "no",
        },
    )

This is a simple version. It checks the database every two seconds and streams changes.

A more advanced version can use Redis pub/sub, so the SSE endpoint receives events when the worker publishes progress.

Example shape:

Worker updates progress
  ↓
Worker publishes event to Redis
  ↓
SSE endpoint listens
  ↓
Browser receives update

That is more efficient, especially when many users are connected.

But for a small product, database-backed SSE is often enough.

Why SSE works well for AI apps

AI products often have multi-stage workflows:

Queued
Fetching data
Cleaning content
Calling AI model
Parsing result
Generating recommendations
Completed

SSE lets you expose those stages to the user naturally.

Instead of hiding the work behind a spinner, you make the process visible.

This improves trust.

Even better, SSE can stream partial results. For example, your worker can save sections of a report one by one, and the frontend can reveal them as they become available.

The page feels alive.

WebSockets for interactive apps

WebSockets are better when the frontend needs to send realtime messages back to the server.

For example:

User sends chat message
  ↓
Server receives instantly
  ↓
Server responds instantly
  ↓
Other connected users receive message

Or:

User changes document
  ↓
Server broadcasts change
  ↓
Other users see update

SSE cannot handle this cleanly because SSE only streams from server to browser. The browser can still send normal fetch() requests, but if communication needs to be continuous and bidirectional, WebSockets fit better.

Common WebSocket use cases:

  • chat apps

  • team collaboration

  • live editing

  • multiplayer interactions

  • realtime notifications with acknowledgement

  • live support tools

  • interactive AI agents

  • audio streaming

  • trading or monitoring dashboards

A minimal Node WebSocket server might look like this:

import { WebSocketServer } from "ws";

const wss = new WebSocketServer({ port: 8001 });

wss.on("connection", (socket) => {
  socket.on("message", (message) => {
    const data = JSON.parse(message.toString());

    if (data.type === "ping") {
      socket.send(JSON.stringify({ type: "pong" }));
    }
  });

  socket.send(JSON.stringify({ type: "connected" }));
});

In the frontend:

const socket = new WebSocket("wss://api.example.com/ws");

socket.onopen = () => {
  socket.send(JSON.stringify({ type: "subscribe", channel: "dashboard" }));
};

socket.onmessage = (event) => {
  const data = JSON.parse(event.data);
  console.log(data);
};

For a Python backend, you might use FastAPI or Django Channels for WebSockets.

Example with FastAPI:

from fastapi import FastAPI, WebSocket

app = FastAPI()

@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
    await websocket.accept()

    while True:
        data = await websocket.receive_text()
        await websocket.send_json({
            "type": "echo",
            "message": data
        })

WebSockets are powerful, but they introduce more state.

You now care about:

  • connected users

  • reconnects

  • missed messages

  • auth during connection

  • connection cleanup

  • heartbeats/pings

  • horizontal scaling

  • broadcasting across servers

For serious use, you usually need a message broker like Redis pub/sub, NATS, RabbitMQ, or Kafka behind the WebSocket layer.

Nginx config for streaming connections

Nginx is the front door in a self-hosted setup.

Normal request-response proxying is easy. Streaming connections require a little more care.

Basic Next.js proxy

server {
    listen 443 ssl http2;
    server_name example.com;

    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;

        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

This is enough for normal Next.js pages.

SSE Nginx config

For SSE, you usually want to disable buffering.

location /api/events/ {
    proxy_pass http://127.0.0.1:8000;
    proxy_http_version 1.1;

    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

    proxy_buffering off;
    proxy_cache off;
    proxy_read_timeout 3600;

    add_header X-Accel-Buffering no;
}

The important parts:

proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600;
add_header X-Accel-Buffering no;

Without this, Nginx may buffer the response and the browser may not receive events immediately.

Your backend should also return:

Content-Type: text/event-stream
Cache-Control: no-cache
X-Accel-Buffering: no

WebSocket Nginx config

For WebSockets, you need upgrade headers.

At the top-level Nginx config, usually inside http {}:

map $http_upgrade $connection_upgrade {
    default upgrade;
    '' close;
}

Then in your server block:

location /ws/ {
    proxy_pass http://127.0.0.1:8001;
    proxy_http_version 1.1;

    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection $connection_upgrade;

    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

    proxy_read_timeout 3600;
    proxy_send_timeout 3600;
}

The important parts:

proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;

Without these, the WebSocket handshake may fail.

Same domain layout

You can serve everything from one domain:

example.com           → Next.js
example.com/api/      → Backend API
example.com/events/   → SSE
example.com/ws/       → WebSockets

Example:

server {
    listen 443 ssl http2;
    server_name example.com;

    location / {
        proxy_pass http://127.0.0.1:3000;
    }

    location /api/ {
        proxy_pass http://127.0.0.1:8000;
    }

    location /events/ {
        proxy_pass http://127.0.0.1:8000;
        proxy_buffering off;
        proxy_cache off;
        proxy_read_timeout 3600;
        add_header X-Accel-Buffering no;
    }

    location /ws/ {
        proxy_pass http://127.0.0.1:8001;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_read_timeout 3600;
    }
}

This keeps the frontend simple because everything is under the same origin.

What changes when you scale horizontally?

Realtime is easy on one server because all connections and application state live in one place.

One server
  ├── Next.js
  ├── API
  ├── SSE connections
  ├── WebSocket connections
  ├── Redis
  └── Workers

But once you add multiple servers, things change.

Load balancer
  ├── App server 1
  ├── App server 2
  └── App server 3

Now a user might be connected to App Server 1, but a worker might publish progress from App Server 3.

You need a shared communication layer.

Usually that means Redis, NATS, RabbitMQ, Kafka, or another message broker.

Scaling SSE

With one server:

Worker updates job
  ↓
SSE endpoint streams to browser

With multiple servers:

Worker publishes progress to Redis
  ↓
Any app server can receive event
  ↓
Correct connected browser gets update

A common pattern:

Worker
  ↓
Redis pub/sub
  ↓
SSE process
  ↓
Browser

If each app server subscribes to Redis, it can forward relevant events to users connected to that server.

You also need to think about reconnects.

SSE has a built-in reconnection behavior, but your backend should be able to resume or send the latest status from the database when the client reconnects.

That is why it is good to store progress in the database, not only in memory.

Scaling WebSockets

WebSockets are trickier because connections are stateful.

If User A is connected to Server 1 and User B is connected to Server 2, a message from User A needs to reach User B.

That requires a shared broker:

Server 1 receives message
  ↓
Publishes to Redis/NATS
  ↓
Server 2 receives event
  ↓
Server 2 sends to connected user

You may also need sticky sessions at the load balancer, where the same user keeps connecting to the same backend server.

But sticky sessions alone are not enough for broadcasting across servers. They help with connection stability, but you still need shared pub/sub for cross-server messages.

Avoid in-memory-only state

On one server, it is tempting to store connected users, job progress, or room data in memory.

That works until you scale.

Better rule:

Temporary connection state can be in memory.
Important application state should be in Redis or the database.

For example:

  • connected socket IDs can be memory

  • current report status should be database

  • pub/sub events can be Redis

  • missed messages should be persisted if they matter

This makes the system easier to scale later.

8. Promotion path: one server to scalable infrastructure

A self-hosted realtime stack can start very simple.

Stage 1: Everything on one server

Single server
  ├── Nginx
  ├── Next.js
  ├── Backend API
  ├── SSE/WebSocket server
  ├── Redis
  ├── Workers
  └── Database

This is good enough for:

  • MVPs

  • SaaS dashboards

  • internal tools

  • early AI products

  • admin portals

  • low-to-medium traffic apps

It is simple, cheap, and easy to debug.

Stage 2: Move database out

App server
  ├── Nginx
  ├── Next.js
  ├── API
  ├── Realtime
  └── Workers

Managed PostgreSQL

This makes data safer and backups easier.

Stage 3: Move Redis out

App server
Managed PostgreSQL
Managed Redis

Now Redis becomes a shared queue and pub/sub layer.

This is important before adding more app servers.

Stage 4: Split workers

Web server
  ├── Next.js
  ├── API
  └── Realtime

Worker server
  └── Background jobs

Managed Redis
Managed PostgreSQL

This helps when background jobs are CPU-heavy or memory-heavy.

For example, AI jobs, scraping jobs, file processing, video processing, and large imports can run on worker machines without slowing down the web app.

Stage 5: Add load balancer and multiple app servers

Load balancer
  ├── App server 1
  ├── App server 2
  └── App server 3

Worker servers
Managed Redis
Managed PostgreSQL

At this stage, Redis or another broker becomes essential for realtime coordination.

For SSE:

Worker → Redis → App servers → Browsers

For WebSockets:

Socket server 1 ↔ Redis/NATS ↔ Socket server 2

Stage 6: Dedicated realtime service

Eventually, you may separate realtime completely.

Next.js servers
API servers
Worker servers
Realtime servers
Managed Redis/NATS
Managed PostgreSQL

This is useful when WebSocket traffic grows independently from normal HTTP traffic.

For example, a chat app may have many open connections but not many normal page requests.

Stage 7: Containerize or orchestrate

Once the architecture is mature, you can move to:

  • Docker Compose

  • AWS ECS

  • Kubernetes

  • Nomad

  • Fly.io

  • Render

  • Railway

  • managed container platforms

But the important thing is this:

You do not need to start there.

If your app already has clean boundaries, moving to containers later is much easier.

The boundaries are:

Frontend
Backend API
Realtime server
Worker
Database
Redis/message broker

Good boundaries matter more than fancy infrastructure.