If you're here, most probably you have issues understanding how Server Side Rendering (SSR) works in meta frameworks like Next.js. I had them too and this is why I decided that I want to learn how they work under the hood and the best thing to do it - build it yourself.
Of course you could also read Next.js source code, but it's really hard to understand it if you never had experience with projects so big and complex.
So, where are we at?
Understand Server Side Rendering
First, to get a good understanding of what we are about to do, let's talk about what Server Side Rendering is.
Server Side Rendering is a process where a web server generates an HTML page on the server itself, rather than letting JavaScript handle that on the client (browser).
When a user visits a web page, the server will:
Get the data from the database if required
Inject this data into the HTML template
Respond with the resulting HTML back to the client
In the end, the browser would receive a generated HTML page and then request all the necessary static assets like CSS, JavaScript, etc.
This is a very well-known process that was around for ages, but not in the React world. Next.js (along with other meta-frameworks) has popularised a new approach, where you basically write the same React as usual with a few new ways of fetching data and underlying technology will take care of rendering React on the server and delivering a complete HTML page to the client, instead of originally bare minimum HTML and a lot of JavaScript.
Next.js does way more than just that, but we only care about SSR here ๐
Those who are new in the Next.js world may wonder, how the heck React - a frontend JavaScript framework can be rendered on the server but still has all the features like useState
, useEffect
, etc on the client?
So, let's recreate Next.js Server Side Rendering functionality from scratch and go step by step to understand "what the heck is going on" and "how the heck getServerSideProps
function is even called"?
Welcome to "Learn Next.js Server Side Rendering by building your own" ๐
Project Setup
In order to create SSR functionality, first we need to create that very first 'S' - server. Since I don't know anything else than JavaScript (TypeScript doesn't count, okay?) and also Next.js uses Node.js under the hood, AND ALSO we still need to work with React since it uses JavaScript - let's start with that.
Creating Node.js Server
For this tutorial, I want to create a bare-bone Node.js application and start building it along with you, so we don't miss anything ๐
Ready?
Start New Project
Create a new folder react-ssr
, cd into it and generate a new npm project by running:
npm init
Good news - this part is done ๐คฃ
Create a Basic Express.js Application
Since we are not replicating the Next.js server, but doing a simplified version of it, I will use the Express.js library to create the server and handle requests.
I will use yarn package manager, but you're free to use anything you prefer
Install express
and create a basic Express.js application
yarn add express
In the root of your project create a new file server.js
and paste the following code:
import express from 'express';
const app = express();
const port = 3000;
app.get('/', (req, res) => {
res.send('Hello World!');
});
app.listen(port, () => {
console.log(`Server is listening on port ${port}`);
});
Let's test it out. Start the server from the terminal node server.js
Congratulations! Our first error ๐
Node.js cannot use import
by default, so in order to fix that, we will need to add a transpile step in our workflow.
Trust me, this will help us in the future, so bear with me even if right now it looks like unnecessary comlpexity
To transpile our code, we will use Babel - a JavaScript compiler, that will generate files Node.js is happy with, and Webpack - a JavaScript bundler, that will bundle our code and automate the compilation step.
Configure Babel
First, install all the necessary dependencies by running the following code:
yarn add -D @babel/core @babel/cli @babel/preset-env
This will add packages as devDependencies
.
Now, at the root of your project create .babelrc
file and paste the following configuration inside:
{
"presets": [
"@babel/preset-env"
]
}
Let's test it! Run the following command inside your project:
npx babel server.js --out-file test-server.js
Babel will transpile our server.js
file and create a new test-server.js
file.
Start the project
node test-server.js
If everything went well, congrats your server is started โจ
Configure Webpack
Okay, okay, this all may be a bit hard, but this is the last configuration step, I promise!
Install Webpack dependencies and babel-loader
package
yarn add -D webpack webpack-cli webpack-node-externals babel-loader
Again, adding packages to your devDependencies
.
In the root of your project create a new file webpack.config.js
and paste the following code inside:
const path = require('path');
const nodeExternals = require('webpack-node-externals');
module.exports = [
// Server-side configuration
{
entry: './server.js',
target: 'node', // Compiles for node.js environment
externals: [nodeExternals()], // Excludes node_modules from the server bundle
output: {
filename: 'server.js',
path: path.resolve(__dirname, 'dist'),
publicPath: '/static/'
},
module: {
rules: [
{
test: /\.(js|jsx)$/,
exclude: /node_modules/,
use: 'babel-loader'
}
]
},
resolve: {
extensions: ['.js', '.jsx']
},
mode: 'development'
}
];
Here we are simply telling Webpack to get entry file for server from the root folder and after everything is bundled store the final file inside dist folder.
Just try to read it top to bottom line by line and you'll see it ๐
And let's create a new script inside package.json
file to run our app from a single command. Replace "scripts"
object with the following code.
// you can remove "test" script from the file
"scripts": {
"build": "webpack --watch",
"start": "nodemon dist/server.js"
},
What does it do?
"build" - telling Webpack to bundle the code as per config and watch if entry files change, if yes - rebuild
"start" - nodemon watches server.js file inside dist folder and if it changes, restarts the server
P.S. if you don't have nodemon installed globally, add it as a dependency to the project - yarn add -D nodemon
So, shall we?
Run yarn build
(or npm run build
if you chose npm) - Webpack will generate the final bundled files.
Run yarn start
- your server should be up and running!
Phew! That was a bit intense, but trust me, we would end up doing it anyway once we get to the React part.
BACK TO THE FUN PART
First React Component
If you still remember what we are here for - good job. If not, I will remind you. We want to render React page on the server and for that, guess what, we need React component! Let's start with something very simple.
Inside the root of your project create a new folder app
and subfolder pages
- this is where we will keep our React page (we are mimicking Next.js after all)
Let's install React and React-DOM
yarn add react react-dom
And also Babel React preset as devDependency
yarn add -D @babel/preset-react
Now our project is ready to use React. Inside app/pages/index.jsx
create a very basic React component:
import React from 'react';
export const HomePage = () => {
return (
<div>
<h1>Home Page</h1>
</div>
);
}
Good, but, how do we render it? ๐ค
Rendering React Component on The Server
Luckily there's already a function for us available from the react-dom
package - renderToString - you can read more about it here Server React DOM APIs.
As per React docs renderToString
renders a React tree to an HTML string - exactly what we need in order to get HTML out of React component!
Let's change our Express app to use the function in order to render HomePage
to string that we later can pass inside the HTML page.
Modifying Express App
My idea is that when a user visits the /
route, HomePage
the component will be rendered. For that, let's change the /
route handler.
Inside the server.js
file:
app.get('/', (req, res) => {
const htmlContent = renderToString(<HomePage />);
res.send(`
<!DOCTYPE html >
<html lang="en">
<head>
<meta charset="UTF-8">
<title>React SSR</title>
</head>
<body>
<div id="root">${htmlContent}</div>
</body>
</html>
`);
});
Don't forget to import renderToString
function and HomePage
component.
import { renderToString } from 'react-dom/server';
import { HomePage } from './app/pages';
So, let's see what it does step by step:
app.get('/', () => {})
- listens to index route (/
) and calls the function when a GET request is received.const htmlContent = renderToString(<HomePage />);
- using thereact-dom/server
function takes React component as an argument and renders it to an HTML string.res.send(`...`);
- injectshtmlContent
inside template HTML string and sends as a response to the client
Check the diagram below to see the overview of this process.
I bet you are eager to try it out!
Well, let's try it ๐
Right...Webpack doesn't know about React. Let's fix it ๐
Add the following to your .babelrc
file:
{
"presets": [
"@babel/preset-env",
"@babel/preset-react" // <---- ADD THIS
]
}
TRY AGAIN!
Alright, the build is successful! Now, let's start it and visit localhost
!
yarn start
Open your browser and navigate to http://localhost:3000
Okay, okay, I won't torture you anymore. Basically, what is happening is that our server.js
file cannot use React unless we import it and rename the file to .jsx
too, so we can use JSX inside of it.
Let's fix that!
Fixing Express / React incompatibility
- First, import React to your
server.js
file
import express from 'express';
import React from 'react'; // <-- ADD THIS
import { renderToString } from 'react-dom/server';
import { HomePage } from './app/pages';
Next, rename
server.js
toserver.jsx
And modify the Webpack config
entry: './server.jsx', // <--- CHANGE .js to .jsx
target: 'node',
externals: [nodeExternals()],
output: {
filename: 'server.js',
path: path.resolve(__dirname, 'dist'),
publicPath: '/static/'
},
Good, let's try it again.
yarn build && yarn start
Go to localhost:3000
YASSSSSS!
Awesome! Now, we have legit Server Side Rendered React code! Congratulations!
Let's Make It Real React
We have managed to render React on the server side and ship it to the client. But...is it even React? ๐ฅฒ
Since we are on our way to mimicking Next.js, let's mimic more of it! Something like getServerSideProps
maybe? ๐
notNextServerSideProps
Let's create a function that will be exported from our page and used to fetch data on Server Side too. Go to app/pages/index.jsx
and add the following code outside of the HomePage
component:
export const notNextServerSideProps = async (fetch) => {
const data = await fetch('https://fakestoreapi.com/products')
.then(res => res.json())
.then(json => json);
return {
props: {
title: 'All Products',
products: data
}
}
}
Inside the notNextServerSideProps
function we are making a call to Fake Store API - an open and free e-commerce store API. Here, we are simply getting a list of products.
Once we get the data, we return it as props
object, adding custom title
.
In order to be able to use fetch in Node.js application, we will install node-fetch package ("node-fetch": "^2.6.7") and pass it as an argument.
This is a workaround(!), but for a simple prototype will do.
Calling notNextServerSideProps on The Server
First, install node-fetch
the package. We will go with the version 2.6.7
since it is commonjs package and it will save us some time.
yarn add -D node-fetch@2.6.7
Let's modify the /
route handler and call notNextServerSideProps
in order to fetch the data on the server.
app.get('/', async (req, res) => {
const initialData = await notNextServerSideProps(fetch);
const htmlContent = renderToString(<HomePage {...initialData.props} />);
res.send(`
<!DOCTYPE html >
<html lang="en">
<head>
<meta charset="UTF-8">
<title>React SSR</title>
</head>
<body>
<div id="root">${htmlContent}</div>
</body>
</html>
`);
});
First, we made
app.get
callback an async function, so we can await a response from the Fake Store APIinitialData
- data returned fromnotNextServerSideProps
function<HomePage {...initialData.props} />
- pass theinitialData.props
as props toHomePage
component.
Don't forget to update your imports! (I forgot while writing this article)
import { HomePage, notNextServerSideProps } from './app/pages'; // <--
const fetch = require('node-fetch'); //<-- using node-fetch library
Rendering React Component with Props
Now, we will need to change the HomePage
component, so it can actually receive and use props inside.
export const HomePage = ({ title }) => {
return (
<div>
<h1>{title}</h1>
</div>
);
}
For now, we will keep it simple and render title
only. Save files, build (yarn build
) project and start it again, then visit localhost:3000
.
Good! The notNextServerSideProps
function is running and passing props down to the React component, which then renders with the dynamic data.
Let's utilize the data we receive from Fake Store API then!
export const HomePage = ({ title, products }) => {
return (
<div>
<h1>{title}</h1>
{products.map(product => (
<div
key={product.id}
style={{
display: 'flex',
flexDirection: 'column',
width: '200px',
border: '1px solid black'
}}>
<p>{product.title}</p>
<p>${product.price}</p>
<p>{product.description}</p>
</div>
))}
</div>
);
Here, we destructure products
from props
and map over them in order to display product data. We also add some inline style just to help us visualize it better (it will be ugly - bear with me).
Once again, build -> start -> localhost:3000
.
GOOOOOOOOOOOOOOD!
We have implemented a very hacky and very simplified version of Next.js getServerSideProps ourselves! How cool is that, huh?!
I Said Real React
React is not only about mapping over an array of data and rendering it. React is all about 'reactivity' (pun intended). And this is what our app is lacking at the moment.
But, how can we add reactivity to the page if we don't have any JavaScript inside the HTML page and especially nothing close to React itself - this is where Hydration comes into play.
What is Hydration?
As per ChatGPT
Hydration: To make this static content interactive, React needs to attach event listeners and establish its internal representation of the page. This process is called "hydration." During hydration, React will preserve the server-rendered markup and attach event handlers to it, effectively turning the static content into a dynamic React application.
What it means is that React will take over the rendering inside the browser, use the HTML and data provided by the server and make things clickable, interactive, and reactive.
Luckily for us (again) React team has another function for us called hydrateRoot
. You can read more about it here - React Client APIs.
It's very easy to use this function. We need to create an entry point into our Client application, get the root
element of our app and using hydrateRoot
- hydrate components into the root
element (sorry for the tautology).
Create Client Application
To create an entry point to our application, inside app
folder, let's create app.jsx
file and write some code inside:
import React from 'react';
import { hydrateRoot } from 'react-dom/client';
import { HomePage } from './pages';
const domNode = document.getElementById('root');
hydrateRoot(domNode, <HomePage />);
As we learned before, we get root
element from the DOM and hydrate component inside it using hydrateRoot
function.
But this will lead to an error, many errors to be honest, but let's start one by one.
It's extremely important that SSR React output (HTML) and CSR React output (HTML) are matching, otherwise React will not be able to render and attach event listeners properly.
In order to make sure that the data available to HomePage
the component is exactly the same, Next.js injects this data as a global variable inside window
the object as part of the HTML it returns to the client.
Let's do it together but before that!
Extract HTML Template in The Separate Function
So far we were injecting htmlContent
directly in the string inside the /
route handler. This is of course not optimal, since the more routes we get, the more HTML templates we will have hardcoded in our project.
To avoid that and make the template more versatile, let's create document.js
file and document
function inside it.
By the way, this will be simplified version of Next.js _document.js file
Create utils/document.js
file in the root of your project and paste the following code:
export const document = (htmlContent) => {
return `
<!DOCTYPE html >
<html lang="en">
<head>
<meta charset="UTF-8">
<title>React SSR</title>
</head>
<body>
<div id="root">${htmlContent}</div>
</body>
</html>
`;
}
Now, go back to server.jsx
and change the route handler:
//...
import { document } from './utils/document';
//...
app.get('/', async (req, res) => {
const initialData = await notNextServerSideProps(fetch);
const htmlContent = renderToString(<HomePage {...initialData.props} />);
const html = document(htmlContent);
res.send(html);
});
We can reuse this function for future pages!
Pass Initial Data to HTML
Let's add initialData
that we receive from the notNextServerSideProps
function and inject it into HTML. For this, we need to pass it on to the document
function and then add it as a <script>
tag inside HTML template.
Open document.js
file and replace the existing code with the new one:
export const document = (htmlContent, initialData) => {
return `
<!DOCTYPE html >
<html lang="en">
<head>
<meta charset="UTF-8">
<title>React SSR</title>
</head>
<body>
<div id="root">${htmlContent}</div>
<script>window.__SSR_DATA__ = ${JSON.stringify(initialData)}</script>
</body>
</html>
`;
}
initialData
- addedinitialData
as an argument to the function<script>
- inside this script tag, we set a new property towindow
object -__SSR_DATA__
that will be available globally inside the app.initialData
has to be stringified in order to be transferred across the network.
Now, when the Client Application is loaded in the root
of our HTML, it can access the initialData
and use it in order to hydrate the component!
And add initialData.props
to the document
function inside route handler:
app.get('/', async (req, res) => {
const initialData = await notNextServerSideProps(fetch);
const htmlContent = renderToString(<HomePage {...initialData.props} />);
const html = document(htmlContent, initialData.props);
res.send(html);
});
Adding initialData
to HomePage Component
Since the data is available in the window
object, we can easily access it from the Client Application.
Open app.jsx
and add the following:
/* imports */
const initialProps = window.__SSR_DATA__;
const domNode = document.getElementById('root');
hydrateRoot(domNode, <HomePage {...initialProps} />);
getting
initialProps
from thewindow
objectpassing
initialProps
asHomePage
props
Now, when React will hydrate the component on the Client Side, it will have access to the same exact data as the Server and the contents will match.
Running Client Application
Our latest challenge is to run a Client Application because so far, we are only rendering HTML on the server and sending it to the browser, but we don't really run React app in the browser.
Remember I said "no more Webpack"? I LIED.
First of all, we need to transpile our React code into browser-readable code using Babel and Webpack, so all imports are bundled together.
Inside your webpack.config.js
paste the following before or after the server-side config:
{
entry: './app/app.jsx', // Entry point for your client-side code
output: {
filename: 'app.js',
path: path.resolve(__dirname, 'dist'),
publicPath: '/static/' // Important for dynamic imports to know where to fetch bundles
},
module: {
rules: [
{
test: /\.(js|jsx)$/,
exclude: /node_modules/,
use: 'babel-loader'
}
]
},
resolve: {
extensions: ['.js', '.jsx']
},
mode: 'development'
},
Again, no magic here, just simple "where grab the file" and "where to put this file".
Once this is done, Webpack will transpile and bundle app.jsx
to app.js
and keep it in the dist
folder.
Now, we need to actually make our HTML request for the Client Application aka app.js
. Express.js provides us with a simple middleware to set up a static folder, from where HTML can request files. Let's do that.
app.use('/static', express.static(path.join(__dirname)));
// THIS CODE SHOULD BE BEFORE app.get
// app.get('/', async (req, res) => {
Since the output file (after transpile and bundling) will be located inside dist
folder (dist/server.js
), we set static
middleware to point to the same directory.
don't forget to import path ๐ (yes I forgot again)
Now, when HTML requests for static content (JS, CSS, images) it will be able to call /static
route and get what it needs.
The very last step is to add app.js
to the HTML. Open your document.js
and add the following after the initialData
script tag.
<script src="/static/app.js"></script>
Everything is ready! Let's try it out!
build -> start -> localhost:3000
Everything looks the same as before...But, there are no errors and it means that everything worked! Test time!
Testing SSR React Application
Let's make a simple test!
We will create a counter on top of the page and for every product card add a button that onClick
will console log product name.
Go back to app/pages/index.jsx
and replace HomePage
component:
export const HomePage = ({ title, products }) => {
const [count, setCount] = useState(0);
return (
<div>
<h1>{title}</h1>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
{products.map(product => (
<div
key={product.id}
style={{
display: 'flex',
flexDirection: 'column',
width: '200px',
border: '1px solid black'
}}>
<p>{product.title}</p>
<p>${product.price}</p>
<p>{product.description}</p>
<button onClick={() => console.log(product.title)}>Console</button>
</div>
))}
</div>
</div>
);
}
Don't forget to import useState
๐
import React, { useState } from 'react';
Okay! Fingers crossed!
build -> start -> localhost:3000
BOOM! ๐ฅ
We have created a very simple, but working React Application with Server Side Rendering!
Almost as good as Next.js itself ๐ Gret job!
Conclusions
No conclusions ๐
You've done a great job coming so far! Round of applause for you!
We dug a bit deeper into the hows of Next.js and next time, we will go deeper or broader. How about we dive deep into how App Router works under the hood? ๐
Ask your questions below if you have any and please share this article with those who have doubts on Next.js SSR.
Find me on X ๐
Top comments (0)