Let’s see what it takes to achieve a very basic server side rendered website using Node.
Getting started
Let’s set up the project. First let’s install nodemon
so that we don’t need to refresh the page browser every time we make a change to the js code.
npm install --save-dev nodemon
Then let’s create a file called server.js
at the root of the project.
import http from 'node:http';
import fs from 'fs';
const hostname = 'localhost';
const port = '8000';
const server = http.createServer((req, res) => {
res.statusCode = 200;
if (req.url === '/') {
res.setHeader('Content-Type', 'text/html; charset=utf-8');
fs.readFile('./index.html', 'utf-8', (err, data) => res.end(data));
}
});
server.listen(port, hostname, () =>
console.log(`Server running at http://${hostname}:${port}/`)
);
The code is saying to send back the text from a file called index.html
if a request is made to /
. So let’s create the html file.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
Meow meow meow
</body>
</html>
Now if we run npm start
and navigate to http://localhost:8000/
, we should see “Meow meow meow” in the browser.
Serve more files
Okay, pretty cool, we are serving a website already. If you had multiple pages on your site, you could simply add more if statements to the server
function.
const server = http.createServer((req, res) => {
res.statusCode = 200;
if (req.url === '/') {
res.setHeader('Content-Type', 'text/html; charset=utf-8');
fs.readFile('./index.html', 'utf-8', (err, data) => res.end(data));
}
if (req.url === '/blog') {
res.setHeader('Content-Type', 'text/html; charset=utf-8');
fs.readFile('./blog.html', 'utf-8', (err, data) => res.end(data));
}
});
We an make this a little smarter by dynamically serving html files based on the request url.
import path from 'path'
//
// ...existing code
//
const server = http.createServer((req, res) => {
// Parse the request URL
const url = req.url === '/' ? 'index.html' : req.url;
let filePath = path.join('./', url);
// add .html if no extension
if (!path.extname(filePath)) {
filePath = filePath.concat('.html');
}
// Check if the requested file exists
fs.access(filePath, fs.constants.F_OK, (err) => {
if (err) {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('404 Not Found');
} else {
fs.readFile(filePath, (error, content) => {
if (error) {
res.writeHead(500, { 'Content-Type': 'text/plain' });
res.end('500 Internal Server Error');
} else {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(content, 'utf-8');
}
});
}
});
});
What if we want to be able to serve a css file?
If we add a file called style.css
and add some styles…
body {
background-color: darkslategray;
color: white;
}
And then link the file in the index.html
file…
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<h1>Home</h1>
Meow meow meow
</body>
</html>
The server does end up sending the file but the browser does not seem to able to read it since we are not telling the browser that this is a css
file. So let’s fix that.
Let’s create a new function that gets the correct contentType
based on the file extension.
function getContentType(filePath) {
const extname = path.extname(filePath);
switch (extname) {
case '.html':
return 'text/html';
case '.css':
return 'text/css';
case '.js':
return 'text/javascript';
default:
return 'text/plain';
}
}
const server = http.createServer((req, res) => {
// Parse the request URL
const url = req.url === '/' ? 'index.html' : req.url;
let filePath = path.join('./', url);
// add .html if no extension
if (!path.extname(filePath)) {
filePath = filePath.concat('.html');
}
// Check if the requested file exists
fs.access(filePath, fs.constants.F_OK, (err) => {
if (err) {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('404 Not Found');
} else {
fs.readFile(filePath, (error, content) => {
const contentType = getContentType(filePath); // <--
if (error) {
res.writeHead(500, { 'Content-Type': 'text/plain' });
res.end('500 Internal Server Error');
} else {
res.writeHead(200, { 'Content-Type': contentType }); // <--
res.end(content, 'utf-8');
}
});
}
});
});
Now the home page should be styled. We should also be able to load js files with script
tags in the html.
Render dynamic data
Of course, the advantage of server-side rendering is that you can render dynamically based on some request input. So far our site is static. Let’s make it dynamic.
We need to check if we are on the blog page. If so, we fetch and then render dynamic data. Using jsonplaceholder
for fetching sample data.
const server = http.createServer(async (req, res) => {
if (req.url === '/blog') {
try {
const response = await axios.get(
'https://jsonplaceholder.typicode.com/posts'
);
let html = `<html lang="en"><head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>Document</title><link rel="stylesheet" href="style.css" /></head><body><h1>Blog</h1><nav><ul><li><a href="/">Home</a></li><li><a href="/about">About</a></li><li><a href="/blog">Blog</a></li></ul></nav>`;
let blogItems = '';
response.data.forEach(
(item) =>
(blogItems = blogItems.concat(
`<div><a href="/blog/${item.id}">${item.title}</a></div>`
))
);
html = html.concat(blogItems).concat('</body></html>');
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(html);
} catch (error) {
console.error(error);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Failed to fetch remote data' }));
}
}
// ...
})
blog.html
no longer needs to be a file since we aren’t ever reading from it anymore. Instead, the whole page gets written inside this if statement. Now each blog post is a link to a url that has the id
of the blog post.
Now to add the route handler for individual blog posts.
if (req.url.startsWith('/blog/')) {
const id = req.url.split('/')[2];
try {
const response = await axios.get(
`https://jsonplaceholder.typicode.com/posts/${id}`
);
let html = `<html lang="en"><head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>Document</title><link rel="stylesheet" href="/style.css" /></head><body><h1>Blog</h1><nav><ul><li><a href="/">Home</a></li><li><a href="/about">About</a></li><li><a href="/blog">Blog</a></li></ul></nav>`;
const title = `<h1>${response.data.title}</h1>`;
const body = response.data.body
.split('\n')
.map((paragraph) => `<p>${paragraph}</p>`)
.join('');
html = html.concat(title).concat(body).concat('</body></html>');
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(html);
} catch (error) {
console.error(error);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Failed to fetch remote data' }));
}
}
There we have it - the most basic setup for a server side rendered website.
It was surprisingly not too hard to create. It wouldn’t be terribly hard to create a simple website this way. There are a few pain points with this simple approach:
- We have to manage serving static files like
css
andjavascript
. It would probably be better if we had a library such as express that managed that for us. - Dealing with dynamic routes is ugly. We had to find the blog id number in the url manually. Again, I think express could help out with that.
- No way of composing elements as components. Some html is in html files while other html is strings that end up getting concatenated and joined. This would be really bug prone as the website gets more complex.