DEV Community

Cover image for Learn Next.js Server Side Rendering by building your own implementation
Sergii Kirianov
Sergii Kirianov

Posted on

Learn Next.js Server Side Rendering by building your own implementation

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.

Jack Black opens up a book GIF

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:

  1. Get the data from the database if required

  2. Inject this data into the HTML template

  3. 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

Girl building with hammer

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


Enter fullscreen mode Exit fullscreen mode

GIF showing the creation of react-ssr folder and npm init command

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


Enter fullscreen mode Exit fullscreen mode

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}`);
});


Enter fullscreen mode Exit fullscreen mode

Let's test it out. Start the server from the terminal node server.js

Cannot use import outside of the modeule

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


Enter fullscreen mode Exit fullscreen mode

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"
  ]
}


Enter fullscreen mode Exit fullscreen mode

Let's test it! Run the following command inside your project:



npx babel server.js --out-file test-server.js


Enter fullscreen mode Exit fullscreen mode

Babel will transpile our server.js file and create a new test-server.js file.

Start the project



node test-server.js


Enter fullscreen mode Exit fullscreen mode

Screenshot of successfully running Express app on port 3000

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


Enter fullscreen mode Exit fullscreen mode

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'
  }
];


Enter fullscreen mode Exit fullscreen mode

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"
},


Enter fullscreen mode Exit fullscreen mode

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!

successfully built app

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

Kid dancing fun

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)

project structure

Let's install React and React-DOM



yarn add react react-dom


Enter fullscreen mode Exit fullscreen mode

And also Babel React preset as devDependency



yarn add -D @babel/preset-react


Enter fullscreen mode Exit fullscreen mode

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>
  );
}


Enter fullscreen mode Exit fullscreen mode

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>
`);
});


Enter fullscreen mode Exit fullscreen mode

Don't forget to import renderToString function and HomePage component.



import { renderToString } from 'react-dom/server';
import { HomePage } from './app/pages';


Enter fullscreen mode Exit fullscreen mode

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 the react-dom/server function takes React component as an argument and renders it to an HTML string.

  • res.send(`...`); - injects htmlContent inside template HTML string and sends as a response to the client

Check the diagram below to see the overview of this process.

Overview of Client - Server communication

I bet you are eager to try it out!

Well, let's try it ๐Ÿ˜‰

Homepage error

crying man

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
  ]
}


Enter fullscreen mode Exit fullscreen mode

TRY AGAIN!

Successful build

Alright, the build is successful! Now, let's start it and visit localhost!



yarn start


Enter fullscreen mode Exit fullscreen mode

Open your browser and navigate to http://localhost:3000

Another error - React is not defined

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

  1. 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';


Enter fullscreen mode Exit fullscreen mode
  1. Next, rename server.js to server.jsx

  2. 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/'
    },


Enter fullscreen mode Exit fullscreen mode

Good, let's try it again.



yarn build && yarn start


Enter fullscreen mode Exit fullscreen mode

Go to localhost:3000

Success

YASSSSSS!

Awesome! Now, we have legit Server Side Rendered React code! Congratulations!

man dancing

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
    }
  }
}


Enter fullscreen mode Exit fullscreen mode

X (ex Twitter) What is happening screenshot

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


Enter fullscreen mode Exit fullscreen mode

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>
`);
});


Enter fullscreen mode Exit fullscreen mode
  • First, we made app.get callback an async function, so we can await a response from the Fake Store API

  • initialData - data returned from notNextServerSideProps function

  • <HomePage {...initialData.props} /> - pass the initialData.props as props to HomePage 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


Enter fullscreen mode Exit fullscreen mode

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>
  );
}


Enter fullscreen mode Exit fullscreen mode

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.

All products

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>
  );


Enter fullscreen mode Exit fullscreen mode

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.

Server Side Rendered data fetched on server rendered on client

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

X-men Magneto meme

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 />);


Enter fullscreen mode Exit fullscreen mode

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>
    `;
}


Enter fullscreen mode Exit fullscreen mode

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);
});


Enter fullscreen mode Exit fullscreen mode

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>
    `;
}


Enter fullscreen mode Exit fullscreen mode
  • initialData - added initialData as an argument to the function

  • <script> - inside this script tag, we set a new property to window 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);
});


Enter fullscreen mode Exit fullscreen mode

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} />);


Enter fullscreen mode Exit fullscreen mode
  • getting initialProps from the window object

  • passing initialProps as HomePage 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'
},


Enter fullscreen mode Exit fullscreen mode

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) => {


Enter fullscreen mode Exit fullscreen mode

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>


Enter fullscreen mode Exit fullscreen mode

Everything is ready! Let's try it out!

build -> start -> localhost:3000

Product list page with React hydration

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>
  );
}


Enter fullscreen mode Exit fullscreen mode

Don't forget to import useState ๐Ÿ˜…



import React, { useState } from 'react';


Enter fullscreen mode Exit fullscreen mode

Okay! Fingers crossed!

build -> start -> localhost:3000

Gif showing the completed application

BOOM! ๐Ÿ’ฅ

We have created a very simple, but working React Application with Server Side Rendering!

Almost as good as Next.js itself ๐Ÿ˜ Gret job!

BOOM gif image

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)