← Back to DevBytes

Server-Side Rendering with Socket.io: SSR, SSG, ISR

Server-Side Rendering with Socket.io: SSR, SSG, ISR

Modern web applications demand both fast initial page loads and real-time interactivity. Combining Server-Side Rendering (SSR) techniques with Socket.io gives you the best of both worlds: pre-rendered HTML delivered quickly to the browser, paired with persistent WebSocket connections for live updates. In this tutorial, we'll explore how to integrate Socket.io with three rendering strategies — SSR, SSG, and ISR — using Next.js as the framework of choice.

What Is Server-Side Rendering with Socket.io?

Server-Side Rendering (SSR) refers to generating HTML on the server for each request. Static Site Generation (SSG) pre-builds HTML at compile time. Incremental Static Regeneration (ISR) is a hybrid approach that regenerates static pages in the background at a defined interval or on-demand. Socket.io is a library that enables real-time, bidirectional communication between web clients and servers via WebSockets with graceful fallbacks.

When you combine these rendering strategies with Socket.io, you can deliver a fully rendered page immediately while simultaneously opening a live channel for ongoing data updates. This is particularly powerful for dashboards, chat applications, live analytics, collaborative tools, and notification systems.

Why It Matters

Project Setup

Let's start by creating a Next.js application and installing the necessary dependencies. We'll use the App Router, which is the recommended approach for modern Next.js projects.

npx create-next-app@latest ssr-socketio-demo
cd ssr-socketio-demo
npm install socket.io socket.io-client

Next, create a custom server file. This is necessary because Socket.io needs to attach to the underlying HTTP server instance, which Next.js doesn't expose by default in production mode.

// server.js
const { createServer } = require('http');
const { parse } = require('url');
const next = require('next');
const { Server } = require('socket.io');

const dev = process.env.NODE_ENV !== 'production';
const hostname = 'localhost';
const port = 3000;

const app = next({ dev, hostname, port });
const handle = app.getRequestHandler();

app.prepare().then(() => {
  const server = createServer((req, res) => {
    const parsedUrl = parse(req.url, true);
    handle(req, res, parsedUrl);
  });

  const io = new Server(server, {
    cors: {
      origin: '*',
      methods: ['GET', 'POST'],
    },
  });

  io.on('connection', (socket) => {
    console.log('A client connected:', socket.id);

    socket.on('join-room', (room) => {
      socket.join(room);
      io.to(room).emit('user-joined', socket.id);
    });

    socket.on('message', (data) => {
      io.to(data.room).emit('message', data);
    });

    socket.on('disconnect', () => {
      console.log('Client disconnected:', socket.id);
    });
  });

  // Expose io globally for API routes
  globalThis.io = io;

  server.listen(port, () => {
    console.log(`> Ready on http://${hostname}:${port}`);
  });
});

Update your package.json scripts to use the custom server:

{
  "scripts": {
    "dev": "node server.js",
    "build": "next build",
    "start": "NODE_ENV=production node server.js"
  }
}

SSR with Socket.io

SSR renders the page on every request, making it ideal for personalized or frequently changing content. We'll create a page that fetches initial data on the server and then subscribes to live updates via Socket.io.

Creating the SSR Page

// app/dashboard/page.js
import { useEffect, useState } from 'react';
import io from 'socket.io-client';

export default async function DashboardPage() {
  // This runs on the server for every request
  const initialData = await fetchInitialMetrics();

  return <DashboardClient initialData={initialData} />;
}

async function fetchInitialMetrics() {
  // Simulate fetching from a database or external API
  return {
    activeUsers: 1247,
    requestsPerMinute: 342,
    avgResponseTime: 89,
    timestamp: new Date().toISOString(),
  };
}

function DashboardClient({ initialData }) {
  const [metrics, setMetrics] = useState(initialData);
  const [socket, setSocket] = useState(null);

  useEffect(() => {
    const socketInstance = io();
    setSocket(socketInstance);

    socketInstance.on('metrics-update', (newMetrics) => {
      setMetrics(newMetrics);
    });

    return () => {
      socketInstance.disconnect();
    };
  }, []);

  return (
    <div>
      <h1>Live Dashboard (SSR)</h1>
      <p>Last updated: {metrics.timestamp}</p>
      <ul>
        <li>Active Users: {metrics.activeUsers}</li>
        <li>Requests/min: {metrics.requestsPerMinute}</li>
        <li>Avg Response Time: {metrics.avgResponseTime}ms</li>
      </ul>
    </div>
  );
}

Broadcasting Updates from an API Route

To push updates to connected clients, create an API route that emits events through the shared Socket.io instance:

// app/api/metrics/route.js
import { NextResponse } from 'next/server';

export async function POST(request) {
  const body = await request.json();

  if (globalThis.io) {
    globalThis.io.emit('metrics-update', {
      activeUsers: body.activeUsers,
      requestsPerMinute: body.requestsPerMinute,
      avgResponseTime: body.avgResponseTime,
      timestamp: new Date().toISOString(),
    });
  }

  return NextResponse.json({ success: true });
}

SSG with Socket.io

Static Site Generation builds pages at compile time. This is perfect for content that doesn't change often but still benefits from real-time updates after the initial load. The page is pre-rendered with default data, and Socket.io takes over to push any subsequent changes.

Creating the SSG Page

// app/blog/[slug]/page.js
import { useEffect, useState } from 'react';
import io from 'socket.io-client';

export async function generateStaticParams() {
  const posts = await fetchAllPosts();
  return posts.map((post) => ({ slug: post.slug }));
}

async function fetchAllPosts() {
  return [
    { slug: 'getting-started', title: 'Getting Started' },
    { slug: 'advanced-tips', title: 'Advanced Tips' },
  ];
}

async function fetchPost(slug) {
  return {
    slug,
    title: slug === 'getting-started' ? 'Getting Started' : 'Advanced Tips',
    content: 'This is the pre-rendered content of the post.',
    views: 0,
    likes: 0,
    lastUpdated: new Date().toISOString(),
  };
}

export default async function BlogPostPage({ params }) {
  const post = await fetchPost(params.slug);
  return <BlogPostClient initialPost={post} />;
}

function BlogPostClient({ initialPost }) {
  const [post, setPost] = useState(initialPost);

  useEffect(() => {
    const socket = io();

    socket.emit('join-room', `post:${initialPost.slug}`);

    socket.on('post-updated', (updatedPost) => {
      setPost(updatedPost);
    });

    socket.on('view-count', (views) => {
      setPost((prev) => ({ ...prev, views }));
    });

    return () => socket.disconnect();
  }, [initialPost.slug]);

  return (
    <article>
      <h1>{post.title}</h1>
      <p>Views: {post.views} | Likes: {post.likes}</p>
      <p>{post.content}</p>
      <small>Last updated: {post.lastUpdated}</small>
    </article>
  );
}

With SSG, the HTML is generated once at build time. The Socket.io connection enhances the static page by streaming live view counts, likes, and content edits without requiring a full page reload.

ISR with Socket.io

Incremental Static Regeneration combines the speed of static generation with the freshness of server rendering. Pages are cached and served statically, but the server can regenerate them in the background at a specified interval or on-demand. Pairing ISR with Socket.io means users see cached content instantly, receive live updates via WebSocket, and the underlying cache refreshes automatically.

Creating the ISR Page

// app/products/page.js
import { useEffect, useState } from 'react';
import io from 'socket.io-client';

async function fetchProducts() {
  const res = await fetch('https://api.example.com/products');
  return res.json();
}

export const revalidate = 60; // Regenerate every 60 seconds

export default async function ProductsPage() {
  const products = await fetchProducts();
  return <ProductsClient initialProducts={products} />;
}

function ProductsClient({ initialProducts }) {
  const [products, setProducts] = useState(initialProducts);
  const [liveUpdates, setLiveUpdates] = useState(0);

  useEffect(() => {
    const socket = io();

    socket.on('product-stock-update', ({ productId, inStock }) => {
      setProducts((prev) =>
        prev.map((p) =>
          p.id === productId ? { ...p, inStock } : p
        )
      );
      setLiveUpdates((count) => count + 1);
    });

    socket.on('price-change', ({ productId, price }) => {
      setProducts((prev) =>
        prev.map((p) =>
          p.id === productId ? { ...p, price } : p
        )
      );
    });

    return () => socket.disconnect();
  }, []);

  return (
    <div>
      <h1>Products (ISR)</h1>
      <p>Live updates received: {liveUpdates}</p>
      <ul>
        {products.map((product) => (
          <li key={product.id}>
            {product.name} - ${product.price}
            {product.inStock ? ' (In Stock)' : ' (Out of Stock)'}
          </li>
        ))}
      </ul>
    </div>
  );
}

On-Demand Revalidation with Socket.io

You can trigger ISR revalidation on-demand when specific events occur. This is useful when you want the static cache to update immediately after a database change rather than waiting for the interval.

// app/api/revalidate/route.js
import { revalidatePath } from 'next/cache';
import { NextResponse } from 'next/server';

export async function POST(request) {
  const { path, event } = await request.json();

  // Revalidate the specified path
  revalidatePath(path);

  // Also notify connected clients in real time
  if (globalThis.io) {
    globalThis.io.emit('cache-revalidated', { path, event });
  }

  return NextResponse.json({ revalidated: true, path });
}

Handling Socket.io Connections in a Custom Hook

To avoid duplicating connection logic across components, create a reusable hook that manages the Socket.io lifecycle:

// hooks/useSocket.js
import { useEffect, useRef, useState } from 'react';
import io from 'socket.io-client';

export function useSocket(eventHandlers = {}) {
  const socketRef = useRef(null);
  const [isConnected, setIsConnected] = useState(false);

  useEffect(() => {
    const socketInstance = io();

    socketRef.current = socketInstance;

    socketInstance.on('connect', () => setIsConnected(true));
    socketInstance.on('disconnect', () => setIsConnected(false));

    // Register all event handlers
    Object.entries(eventHandlers).forEach(([event, handler]) => {
      socketInstance.on(event, handler);
    });

    return () => {
      Object.entries(eventHandlers).forEach(([event, handler]) => {
        socketInstance.off(event, handler);
      });
      socketInstance.disconnect();
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  const emit = (event, data) => {
    if (socketRef.current) {
      socketRef.current.emit(event, data);
    }
  };

  return { socket: socketRef.current, isConnected, emit };
}

Usage in a component:

// app/chat/page.js
'use client';
import { useState } from 'react';
import { useSocket } from '@/hooks/useSocket';

export default function ChatPage() {
  const [messages, setMessages] = useState([]);
  const [input, setInput] = useState('');

  const { isConnected, emit } = useSocket({
    'message': (msg) => setMessages((prev) => [...prev, msg]),
  });

  const sendMessage = () => {
    emit('message', { text: input, user: 'me' });
    setInput('');
  };

  return (
    <div>
      <p>Status: {isConnected ? 'Connected' : 'Disconnected'}</p>
      <ul>
        {messages.map((m, i) => (
          <li key={i}>{m.user}: {m.text}</li>
        ))}
      </ul>
      <input
        value={input}
        onChange={(e) => setInput(e.target.value)}
        placeholder="Type a message"
      />
      <button onClick={sendMessage}>Send</button>
    </div>
  );
}

Best Practices

Connection Management

State Synchronization

Security

Performance

Hydration Safety

Production Deployment Considerations

When deploying to platforms like Vercel, the custom server approach for Socket.io won't work because serverless functions don't support persistent WebSocket connections. For production deployments with Socket.io, consider the following options:

Here's an example of configuring the client to connect to a separate WebSocket server:

// lib/socket.js
import io from 'socket.io-client';

let socket;

export function getSocket() {
  if (!socket) {
    socket = io(process.env.NEXT_PUBLIC_SOCKET_URL || '', {
      path: '/socket.io',
      transports: ['websocket', 'polling'],
      reconnection: true,
      reconnectionAttempts: 5,
      reconnectionDelay: 1000,
    });
  }
  return socket;
}

Conclusion

Combining Server-Side Rendering strategies with Socket.io gives you a powerful architecture for building fast, SEO-friendly, and real-time web applications. SSR delivers personalized, up-to-date content on every request; SSG provides blazing-fast static pages enhanced with live updates; and ISR offers the perfect middle ground with cached pages that regenerate in the background. By following the patterns and best practices outlined in this tutorial — proper connection management, hydration-safe state initialization, security middleware, and thoughtful deployment choices — you can build robust applications that deliver both instant page loads and continuous real-time interactivity. Start with the rendering strategy that matches your data freshness requirements, layer in Socket.io for live updates, and scale with rooms, namespaces, and a Redis adapter as your user base grows.

— Ad —

Google AdSense will appear here after approval

← Back to all articles