Setting up SSR(Server Side Rendering) with React has always been a nightmare for most of us. But trust me after reading this you will realise that it’s not as difficult as it looks at face. In this post I’ll be demonstrating what are different types of rendering and then we’ll see how you can setup SSR in your new/existing projects from scratch once and for all.
So to begin with, what is SSR?
Before answering that question let’s go one step back What is rendering?
What is Rendering?
In layman terms rendering is a process which puts some pixels on your screen and create something visual with which your users can interact with.
What are different types of rendering?
1. Client Side Rendering(CSR)
This is the simplest and straightforward way of rendering that we do usually knowingly/unknowingly. If you’re using Create React App for creating your react projects you’re doing CSR.
Let’s see how does it looks like
What happens here is that when a client requests your application by entering a URL in the browser the server returns a basic markup with script tags which is the bundle of your application (since now your JS is responsible for rendering the whole app) so the browser now needs to download, parse and execute this JavaScript before anything is shown to the user. Hence we show loaders until all this is happening. Below is a sample Markup that your client receives in a typical CSR model.
1<html>2<head>3 <title>My App</title>4</head>5<body>6 <div id="root"></div>7<body>8<script type="text/javascript" src="https://app.com/js/main-123xcr.js"></script>9</html>
There is no harm in CSR if your product is not concerned with SEO because SEO bots depend purely on semantic HTML and what your users can see before even downloading a single byte of JavaScript and there are some performance gains as well that are attributed to Server Side Rendering
P.S. There are rumors that the SEO bots today are able to parse pure JavaScript applications as well but still the initial HTML markup without downloading a single byte of JavaScript gets preferred and is ranked higher.
2. Server Side Rendering(SSR)
In a typical server sider rendering model we are dependant on a server to generate a basic HTML which has some meaning and then the client will take it up from there and finish the remaining process of attaching the event listeners and making the page interactive for our users. This process is also known as hydration.
This rendering model is also known has hybrid rendering where the server does partial things and the client(browser) takes up from where the server left and does the remaining stuff.
Let’s see how does it looks like
In the above picture the enhanced Html that the server sends looks something like this
1<!DOCTYPE html>2<html>34<head>5 <style data-styled="true" data-styled-version="5.1.1">6 .gYRKyY {7 padding-left: 500px;8 padding-right: 500px;9 padding-top: 240px;10 }1112 /*!sc*/13 @import url('https://rsms.me/inter/inter.css');1415 /*!sc*/16 body {17 margin: 0;18 }1920 /*!sc*/21 * {22 -webkit-tap-highlight-color: rgba(0, 0, 0, 0);23 -webkit-tap-highlight-color: transparent;24 }2526 /*!sc*/27 html {28 font-family: 'Inter', sans-serif;29 }3031 /*!sc*/32 @supports (font-variation-settings:normal) {33 html {34 font-family: 'Inter var', sans-serif;35 }36 }37 </style>38 <title>Resplash</title>39</head>4041<body>42 <div id="app">43 <div>44 <h1 data-component="Heading" class="css-17aexfk-Heading">Resplash</h1>45 <span data-component="Text" class="css-14krc0n-Text">A gallery of amazing photos</span>46 </div>47 <input type="text" placeholder="Search photos with any keyword" data-component="Input" class="css-1t5g2mc-Input" />48 </div>49 </div>50</body>51<script src="http://localhost:9000/example/build/client/js/main.js" defer></script>52</html>
If you look closely the server has actually sent the initial set of styles and the components that can be rendered directly by the browser and then the browser can parallelly download the JavaScript to make the page interactive. So our users essentially can get the visual feedback pretty quickly with SSR.
So all this sounds cool, but how do we create our own SSR setup with React from scratch?
Let’s begin! 🚀
We’ll be building Resplash which is similar to unsplash where you can search for images with any random keyword 😉. I’ll be using this GitHub repository for the demonstration purpose. Here’s a sneak peak of what we’ll be building
Home Page
Search Results Page
SSR, the traditional way
To begin with the setup since we need a server to render something, we need to tell it how it should render and since we need a server we’ll be using an express server for this. React provides a function called renderToString
in react-dom/server
package that we need to use on the server. As the name suggests this method will take your React tree as an input and return the actual Html markup which we can then send to the browser.
Also now since the server is in the picture which means we need to do the rendering twice once on the server and then on the client, oh and what about the data our components need initially 🤔. Yes this is where it starts getting confusing. But relax, we’ll see step by step how we can achieve this.
1. Component Initial Data
First of all we need to identify if the component that gets rendered by the route needs initial data, if yes we need to fetch that data and send it in our Html markup from server. Let’s see how to do that
To begin with we need to convert our declarative routes to imperative route config because we need to refer to our routes on the client as well as on the server.
This is how our routing looks like as of now
1// Wrapper.js2const Wrapper = () => (3 <ThemeProvider tokens={tokens} components={components}>4 <GlobalStyles />5 <Switch>6 <Route exact path="/">7 <Home />8 </Route>9 <Route exact path="/photos">10 <Listing />11 </Route>12 </Switch>13 </ThemeProvider>14);
We’ll make some changes to the above code so first of all let’s create a file routes.js
with the following content
1const routes = [2 {3 path: '/',4 exact: true,5 Component: Home,6 },7 {8 path: '/photos',9 exact: true,10 Component: Listing,11 },12];
Now, let’s modify our Wrapper.js
to accomodate the above change.
1import routes from '../routes/routes';23const Wrapper = () => (4 <ThemeProvider tokens={tokens} components={components}>5 <GlobalStyles />6 <Switch>7 {routes.map(({ path, exact, Component }) => (8 <Route key={path} path={path} exact={exact}>9 <Component />10 </Route>11 ))}12 </Switch>13 </ThemeProvider>14);
The next step is to define the function that will fetch the data for our component on the server and pass the data to our component. In our case we need to do that in the Listing’s page. We’ll define a function called getInitialData
on the Listing
component.
1// Listing.js2const Listing = () => {3 // rendering logic4}56// Function that we'll call on server to fetch the initial data7Listing.getInitialData = async (req) => {8 const { keyword } = req.query;9 const response = await getPhotos(keyword);10 return { photos: response.results };11};
In the next step we’ll see how do we call this function on the server and pass the data to our component.
2. Server Configuration
First of all create a file called server.js
in your project which will create an instance of express server and we’ll be exposing a path on which our app will get rendered. Here’s how it will look like
1import serialize from 'serialize-javascript';23app.get('*', (req, res) => {4 const activeRoute = routes.find((route) => matchPath(req.path, route)) || {};5 let initialData = {};67 if (activeRoute?.Component?.getInitialData) {8 initialData = await activeRoute.Component.getInitialData(req);9 }1011 const sheet = new ServerStyleSheet();1213 // get the html markup of our application14 const markup = renderToString(15 <StyleSheetManager sheet={sheet.instance}>16 <StaticRouter location={req.url}>17 <Wrapper /> {/* Our actual application */}18 </StaticRouter>19 </StyleSheetManager>,20 );21 const styles = sheet.getStyleTags();2223 // send the html markup in response24 res.send(`25 <!DOCTYPE html>26 <html>27 <head>28 ${styles}29 <title>Resplash</title>30 <script>window.__INITIAL_DATA__=${serialize(initialData)}</script>31 </head>32 <body>33 <div id="app">${markup}</div>34 </body>35 <script src="${configJs.assetsUrl}/${packageJson.name}/build/client/js/main.js" defer></script>36 </html>37`);38});
The source of entire server.js
can be found here
Here’s what we are doing in a nutshell:
- For the current requested route we check if the component has
getInitialData
function defined and call it to get the initial data for that route. (Line #8) - Extract the styles from our application - We are using styled-components which provides
StyleSheetManager
to do this job. (Line #15) - Match the route and persist it across client and server - We are using React Router which provides the
StaticRouter
component for this. (Line #16) - Render our actual application. (Line #17)
- Get the style tags from Styled Components for that route. (Line #21)
- Attach the initial data that we fetched for that route in a global
window
object. (Line #30) - Send the Html markup by attaching style tags and our markup from the steps above. (Line #24)
After above things are done open the Listing.js
to handle the initial data that was sent by our server
1// Listing.js2const Listing = () => {3 const [fetching, setFetching] = React.useState(false);4 const [photos, setPhotos] = React.useState([]);5 const search = new URLSearchParams(useLocation().search).get('keyword');67 const fetchPhotos = async (keyword) => {8 setFetching(true);9 const response = await getPhotos(keyword);10 setFetching(false);11 setPhotos(response.results);12 };1314 React.useEffect(() => {15 if (window?.__INITIAL_DATA__?.photos) {16 // render photos - the page was rendered on the server and server sent the data17 setPhotos(window.__INITIAL_DATA__.photos);18 } else {19 // make the api call on client - the page was navigated from client side20 fetchPhotos(search);21 }22 }, [search]);2324 return (25 // render photos26 );27};
So what we are essentially doing here is when the page gets rendered on the server(direct URL request, or browser reload) we will load the data from window.__INITIAL_DATA__.photos
else if the page is navigated from the client side i.e from Home page(typical SPA - single page application) we won’t have initial data from server, hence we will make the api call from the client side.
Now, let’s see the client configuration
3. Client Configuration
We’ll create a file called client.js
and add the following code
1ReactDOM.hydrate(2 <BrowserRouter>3 <Wrapper />4 </BrowserRouter>,5 document.getElementById('app'),6);
The source of entire client.js
can be found here
Let’s see what’s going on here. We used a method called ReactDOM.hydrate
from react-dom
. If you can recall then typically(when doing CSR) we use ReactDOM.render
method but when we are doing SSR we use renderToString
on the server so this method does some amount of work on the server and hence in order to sync the remaining work on the client(browser) side we have to use ReactDOM.hydrate
which basically means hydrate the Html markup returned from the server and don’t start everything from scratch.
So we are able to get the basic SSR working. But there are few issues with this approach.
- Duplication: If you see we are maintaining 2 files now
client.js
andserver.js
and it has a lot of duplicate code. For example we need to write a router on both the client as well as the server. If we add more providers, for example, Redux, Apollo etc someone has to keep going to both the server and client files and add it at both the places so they remain in sync. - Maintenance: It’s risky and adds a lot more overhead because once your application grows, your team grows you basically lose control on all this and if there’s a mismatch in your client and server React tree then react will just throw all the work that was done on the server and instead of hydrating it will just restart all the efforts.
- Performance: This was the main reason why we opted for SSR but if there is a mismatch between the client and the server then the performance goes for a toss since all the work that the server did will now be discarded and react will boot the whole application from scratch which makes it similar to CSR now.
You might be familiar with this warning when the server and client content mismatch
So how shall we address the above problems? 🤔
SSR, the better way
Before we jump to the solution let’s identify what do we expect from the solution?
- A nice abstraction - As a developer I’m only concerned about what my application is meant to do. How and where to render it should be abstracted out.
- Central configuration - I want to have one place which holds all my configuration, instead of multiple places in different files.
- Resilient Setup - Make SSR setup more resilient than fragile.
Now with the above goals in mind let’s see how we can abstract all these things with a nice API.
1. Abstraction
Let’s create a function that’ll abstract all the SSR things for us
1export const renderApp = ({ App, req, initialData = {} }) => {2 if (__IS_BROWSER__) {3 ReactDOM.hydrate(4 <BrowserRouter>5 <App />6 </BrowserRouter>,7 document.getElementById('app'),8 );9 } else {10 const sheet = new ServerStyleSheet();11 const markup = renderToString(12 <StyleSheetManager sheet={sheet.instance}>13 <StaticRouter location={req.url}>14 <App />15 </StaticRouter>16 </StyleSheetManager>,17 );18 const styles = sheet.getStyleTags();19 const html = `20 <!DOCTYPE html>21 <html>22 <head>23 ${styles}24 <title>Resplash</title>25 <script>window.__INITIAL_DATA__=${serialize(initialData)}</script>26 </head>27 <body>28 <div id="app">${markup}</div>29 </body>30 <script src="${__CONFIG__.assetsUrl}/${__NAME__}/build/client/js/main.js" defer></script>31 </html>32 `;33 return html;34 }35};
The complete source code can be found here
Let’s see what’s happening here:
- We have clubbed our
client.js
andserver.js
into one function. - The function accepts the following as input
- The main
App
that needs to be rendered - The
req
object to handle the server things - The initial data that we might send for a particular route.
- The main
- We have one flag
__IS_BROWSER__
(we’ll talk about this in a while) which decides whether this function is being called from client or server. But the developers are completely unaware about this.
Now, let’s see the usage of this function
2. Client configuration
open the client.js
we created earlier
1// old client.js2ReactDOM.hydrate(3 <BrowserRouter>4 <Wrapper />5 </BrowserRouter>,6 document.getElementById('app'),7);
Now replace the above code with the following snippet
1// updated client.js2import { renderApp } from 're-ssr/renderApp';34renderApp({ App: Wrapper });
Open webpack.client.js
and add this to the list of plugins
1plugins: [2 new webpack.DefinePlugin({3 __IS_BROWSER__: 'true',4 }),5]
3. Server configuration
open the server.js
we created earlier
1// old server.js2app.get('*', (req, res) => {3 const sheet = new ServerStyleSheet();45 // get the html markup of our application6 const markup = renderToString(7 <StyleSheetManager sheet={sheet.instance}>8 <StaticRouter location={req.url}>9 <Wrapper /> {/* Our actual application */}10 </StaticRouter>11 </StyleSheetManager>,12 );13 const styles = sheet.getStyleTags();1415 // send the html markup in response16 res.send(`17 <!DOCTYPE html>18 <html>19 <head>20 ${styles}21 <title>Resplash</title>22 </head>23 <body>24 <div id="app">${markup}</div>25 </body>26 <script src="${configJs.assetsUrl}/${packageJson.name}/build/client/js/main.js" defer></script>27 </html>28`);29});
Now replace the above code with the following snippet
1// updated client.js2import { renderApp } from 're-ssr/renderApp';34app.get('*', (req, res) => {5 const activeRoute = routes.find((route) => matchPath(req.path, route)) || {};6 let initialData = {};78 if (activeRoute?.Component?.getInitialData) {9 initialData = await activeRoute.Component.getInitialData(req);10 }1112 const html = renderApp({ App: Wrapper, req, initialData });13 res.send(html);14});
Open webpack.server.js
and add this to the list of plugins
1plugins: [2 new webpack.DefinePlugin({3 __IS_BROWSER__: 'false',4 }),5]
Let’s see what we did in a nutshell:
- Just call one function
renderApp
insideclient.js
as well asserver.js
and pass yourApp
. - Use webpack define plugin to identify the bundle will run on browser or server at compile time only(this also helps reduce bundlesize) which will help the
renderApp
function to either return the server markup or return the hydrated tree for client. Read more about DefinePlugin. - Removed so many lines of code.
Hoorah! we have configured the SSR from scratch the easier and the better way 🎉
Wrap up 📝
Sometimes there are a lot of simple things that we end up doing repetitively and then later get fed up with it which makes things worse and fragile. But that’s also the time when we can look deeper and see if there exists a potential of abstraction. The above abstraction actually originated when I was working at Treebo and whenever we used to refactor or upgrade our existing packages or add any new packages we had to update the React tree and we often used to struggle to keep the changes on both the client and server in sync but it used to get missed somehow becauase again it was a manual tedious process and then our performance used to get affected. So we went deeper and discovered that there exists a potential and hence the above solution was born. After this we never had any sync issues, we removed a lot of redundant code and the maintenance went down to 0 and we used to ship our SSR apps with confidence 💪🏻
Interestingly, we abstracted out this as a common package and then it helped us to move our existing and upcoming projects to SSR with breeze and without any hiccups 🚀
If you have run into problems with SSR and solved them with different solutions I would love to hear your stories. You can write it to me or you can DM me on Twitter.
If you like this then don’t forget to
🐤 Share
🔔 Subscribe
➡️ Follow me on Twitter