Building a Node.js HTTP Server to Understand Single-Page Application Routing and Server-Side Rendering
To simulate a production environment locally, a server must be built to serve a bundled application. This involves using Node.js's http module to create a server that responds to browser requests.
Two React projects are created for comparison: one using create-react-app (CRA) and another using create-next-app (Next.js). The CRA project represents a standard React single-page application (SPA) where routing is client-side. The Next.js project uses file-based routing and supports server-side rendering (SSR) and static generation.
Both projects are built:
- CRA outputs a
builddirectory. - Next.js outputs a
.nextdirectory.
A Node.js script is written to serve these built assets. The core involves the http module:
const server = require('http');
server.createServer((request, response) => {
// Handle request and send response
}).listen(8080);
The createServer method starts a server on port 8080. When a client accesses http://127.0.0.1:8080, the server responds with resources defined in the callback.
Key steps in request handler:
- Parse the client's request URL to determine the requested resource path.
- Map the requested path to the correct file location within the built output directory.
- Use the
fsmodule to read the file asynchronous. - Send the file content in the response.
- Optionally, use
child_processto automatically open a browser to the server URL.
For a Next.js application (SSR-capable), the server logic must handle dynamic routes and server-rendered pages. The built output in .next contains speicfic directories like static for assets and server-side bundles.
Example server snippet for a Next.js app:
const fileSystem = require('fs');
const httpServer = require('http');
const processUtil = require('child_process');
const urlParser = require('url');
const pathUtil = require('path');
const serverInstance = httpServer.createServer(async (incomingReq, outgoingRes) => {
const parsedUrl = urlParser.parse(incomingReq.url, true);
let resourcePath = parsedUrl.pathname;
// Default to index.html for the root
if (resourcePath === '/') {
resourcePath = '/index.html';
}
// Construct absolute file path
let absoluteFilePath = pathUtil.join(__dirname, '.next', 'static', resourcePath);
// If file doesn't exist in static, check other Next.js specific paths (e.g., server pages)
if (!fileSystem.existsSync(absoluteFilePath)) {
// Logic for SSR routes or fallback
absoluteFilePath = pathUtil.join(__dirname, '.next', 'server', 'pages', resourcePath === '/' ? 'index.html' : resourcePath + '.html');
}
fileSystem.readFile(absoluteFilePath, (err, data) => {
if (err) {
outgoingRes.writeHead(404);
outgoingRes.end('File not found');
return;
}
outgoingRes.writeHead(200);
outgoingRes.end(data);
});
});
serverInstance.listen(8080, () => {
console.log('Server running on port 8080');
processUtil.exec('start http://127.0.0.1:8080'); // Opens browser on Windows
});
For a CRA SPA, the server primarily serves index.html for all non-asset routes, enabling client-side routing. The logic is simpler as all routing is handled by React Router in the browser after the initial HTML load.
const fs = require('fs');
const http = require('http');
const url = require('url');
const path = require('path');
const port = 8081;
const spaServer = http.createServer((req, res) => {
const parsed = url.parse(req.url);
let filePath = '.' + parsed.pathname;
// If path is root or a route, serve index.html
if (filePath === './' || !path.extname(filePath)) {
filePath = './build/index.html';
} else {
// Otherwise, serve static files from build directory
filePath = './build' + parsed.pathname;
}
const extname = path.extname(filePath);
let contentType = 'text/html';
switch (extname) {
case '.js':
contentType = 'text/javascript';
break;
case '.css':
contentType = 'text/css';
break;
case '.json':
contentType = 'application/json';
break;
case '.png':
contentType = 'image/png';
break;
case '.jpg':
contentType = 'image/jpg';
break;
}
fs.readFile(filePath, (error, content) => {
if (error) {
if(error.code === 'ENOENT') {
// File not found, fallback to index.html for SPA routing
fs.readFile('./build/index.html', (err, data) => {
if (err) {
res.writeHead(500);
res.end('Server Error');
} else {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(data, 'utf-8');
}
});
} else {
res.writeHead(500);
res.end('Server Error: ' + error.code);
}
} else {
res.writeHead(200, { 'Content-Type': contentType });
res.end(content, 'utf-8');
}
});
});
spaServer.listen(port, () => {
console.log(`SPA server running at http://127.0.0.1:${port}/`);
});
The difference in server logic highlights the fundamental distinction between SPAs and SSR applications. In an SPA, the server returns a single index.html file for most routes, and the client-side JavaScript handles navigation and renders components. In SSR (as with Next.js), the server can generate and return fully rendered HTML for specific routes, improving initial load performance and SEO. The Node.js HTTP server must be configured accordingly to serve the correct files and handle route-specific rendering logic.