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
- Performance: SSR, SSG, and ISR reduce time-to-first-byte and improve Core Web Vitals by serving pre-rendered HTML instead of waiting for client-side JavaScript to mount.
- SEO: Search engines can crawl fully rendered content, which is critical for content-heavy applications.
- Real-time UX: Socket.io keeps data fresh without polling, reducing server load and improving perceived performance.
- Flexibility: ISR lets you cache pages while still updating them periodically, balancing speed with freshness.
- Graceful degradation: Socket.io falls back to long-polling when WebSockets are unavailable, ensuring connectivity in restrictive environments.
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
- Always disconnect sockets in the cleanup function of
useEffectto prevent memory leaks and duplicate connections during React's Strict Mode double-mounting in development. - Use a single Socket.io connection per client. Creating multiple connections degrades performance and wastes server resources.
- Implement reconnection logic. Socket.io handles this by default, but you should account for missed events by requesting a state sync on reconnect.
State Synchronization
- Always pass initial data from the server render to the client component as props. This ensures the client starts with the same state the server rendered, preventing hydration mismatches.
- Use functional state updates (
setPrev(prev => ...)) when applying Socket.io events to avoid race conditions with stale closures. - Consider using a state management library like Zustand or Redux for complex applications with many real-time data streams.
Security
- Restrict CORS origins in production instead of using a wildcard. Only allow your known frontend domains.
- Authenticate socket connections using middleware. Verify JWT tokens or session cookies before accepting connections.
- Validate all incoming and outgoing event payloads. Never trust client-sent data without sanitization.
- Use rooms and namespaces to isolate data between different users or organizations.
Performance
- Use Socket.io rooms to broadcast events only to relevant clients rather than emitting to all connections.
- Batch rapid updates. If your server emits hundreds of events per second, throttle or debounce them on the client to avoid excessive re-renders.
- For ISR pages, set the
revalidateinterval based on how stale data can become before it impacts user experience. - Consider using a Redis adapter for Socket.io when running multiple server instances, so events broadcast across all nodes.
Hydration Safety
- Mark components that use Socket.io as client components with the
'use client'directive when using the App Router. - Avoid rendering time-dependent values (like
Date.now()) directly in SSR output, as they will differ between server and client. Pass them as props from the server instead. - Use
suppressHydrationWarningsparingly only on elements where you intentionally expect differences.
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:
- Self-hosted: Deploy on a VPS or container platform (AWS ECS, DigitalOcean, Railway) where you control the Node.js process and can run the custom server.
- Separate WebSocket service: Host your Next.js app on Vercel and run Socket.io on a separate service. The client connects to both independently.
- Managed WebSocket providers: Use services like Pusher, Ably, or PubNub that handle WebSocket infrastructure for you while integrating with your SSR framework.
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.