Relay modern: SSR and next.js

Photo by Michał Parzuchowski on Unsplash

Next.js is a powerful and versatile framework. It is pretty simple to use and the documentation is pretty great. But some tasks are tricky to handle on the server side. One of them is making Relay work.

Try the official next.js example first. If that works for you, I suggest you go with it. However, the official example needs you to write your graphql queries and fragments a certain way. If you don’t like that, you can write your code as you normally would and then use custom document and Relay network modern SSR to make it work.

How to read this article

The article starts with a discussion of SSR. I strongly advise you to read that once. You can selectively read the article after that.

I have used a very basic next.js app. You can either clone it and work on it as we go. Or you can directly read the code from github. I have added links to the files wherever needed. The code is small, clean and understandable.

This Article is divided into three cases / approaches to SSR. The first one is basic — no SSR support. I suggest you go through it to understand the example.

If you feel that the next.js approach works for you, I suggest you move on to that. You don’t have to read anything else.

If you feel that the universal approach works for you, I suggest you to skip the next.js approach and move directly to that. You don’t have to read anything else.

Table of contents

  1. Introduction
  2. A basic Relay app
  3. A barebones example.
  4. Data availability — the next.js way
  5. Data availability — the universal way

Introduction

This article will explore the problems encountered while using Relay on the server side. We shall see different approaches to migitate those problems and compare them. We will explore these solutions with a very simple example. We move in steps and each step is represented as a commit in the example.

Server side rendering

With SSR set up, your initial app rendering is a little different that the normal React lifecycle. We assume that we are using function components. The normal React flow is following:

  1. Render the Virtual DOM and commit it to HTML DOM.
  2. After the commit phase, let components know that the tree is mounted.
  3. Components run any side effects (dictated by useEffect hooks).

With SSR, the flow is as follows:

  1. Render the Virtual DOM on a node.js server. We must make sure that the rendering code does not use any browser based Web APIs like document or window object. That is to ensure that app renders on server without any issues. Any Web APIs should be used in side effects.
  2. Take the output of rendering and make it into an HTML page.
  3. Serve the HTML page to the client.
  4. React again renders the Virtual DOM on the client side.
  5. It compares it to the HTML recieved and then links the generated Virtual DOM to the HTML DOM. This is called hydration.
  6. After hydration, React lets components know that the app is ready.
  7. Components run any side effects (dictated by useEffect hooks).

Note: If the Virtual DOM rendered on client side does not match the recieved HTML, client side Virtual DOM overrides the HTML recieved from the server. In such a case, React provides a hydration warning in development mode.

The normal React flow with Relay is as follows:

  1. Virtual DOM is rendered. That fires up the graphql queries.
  2. Until the data is not available, you render a placeholder like a loading indicator.
  3. When the data comes in, Relay re-renders the app with the graphql data.

There are two hinderances to using Relay at the server side:

  1. Data fetching should work on server side. For example if you use fetch to get your data, that won’t work on the server.
  2. Data should be available before the app is rendered on server. If that is not the case, recieved HTML will have the loading indicator instead of the graphql data.

Before loading the page, disable the Javascript in your browser. After that, you’ll be able to see the HTML recieved from the server as it is. That is because React does not run on the client side anymore.

To disable Javascript in chrome, open the DevTools. Then press ctrl + shift + P. Search for Disable Javascript option.

A basic Relay app

We shall use the github graphql API. Our example will get your username and render it on the screen. To get started, create a personal access token so that you can use the API. After that, you can clone this example. We make this example SSR ready in steps. Each step is reflected as a commit. You can clone the example by:

git clone https://github.com/yashmahalwal/relay-ssr.git

Before starting, make an environment file in the project root and save it as .env.local. Enter your personal access token inside it.

NEXT_PUBLIC_GITHUB_TOKEN=Your github personal token

Finally, run npm install to get all the dependencies.

1. A barebones example

Now, switch to the corresponding commit . To do that, run

git checkout 72779256ae238553e12dbf96d85722f676a9ae6f

I want you to take a minute to look around. First off, check the relay environment file. It is almost same as the relay docs example with two differences:

  1. The URL is set to github’s API. It also passes a header for github authentication.
  2. It exports a function that returns a new environment object instead of simply exporting an environment object.

The second part is necessary. On the server side, a single copy of the environment.ts module is used for all the clients. So if we export an environment object, it will be shared by all the clients. That means that they will share a common store. This in turn means that a user can see the data of another user. That is a memory leak. To overcome that, we create a new environment on every render.

I use relay-hooks to make things easier. It needs you to provide an environment on the top level as context. That environment is used everywhere. If you look at the custom app, you will see that happening.

FInally, come to the index page. I fire a query to get the username of the authenticated user.

query pagesQuery {
viewer {
login
}
}

Let us see it happening in action. Make sure that you ran npm install in the project root. After that, run npm run dev to start the development server.

Now I want you to go to your browser and open the URL for the development server. By default, it will be set to http://localhost:3000/.

The username query

Let us take a look at the markup recieved from the server. Disable Javascript in your browser and try again.

Server rendered markup

Ideally, we would expect the server side markup to contain the username. But since the server did not have our graphql data before rendering and it did not wait for the Relay’s queries to complete, we are stuck with the loading indicator.

  1. Data fetching: If you look at the environment file, we use fetch at the network layer. It should throw error on the server side but it does not. That is because react-relay package indirectly depends on isomorphic-fetch. This package allows you to use fetch on node.js as you would in the browser. You can verify the dependency by running npm ls in the project root.
output of npm ls command

2. Server side markup: Server sent us a loading indicator instead of the final result. If we need the final result, we must make sure that the data is available before our page is rendered.

There were two issues with Relay on server side — data fetching and data availability. Relay takes care of data fetching. You need to take care of data availability. Thankfully, we have a trick up our sleeve that will help with this. Everything we do will be based on the following property of Relay store:

The store constructor from relay-runtime accepts a records argument. If we provide an object here, the new store is populated with the entries from this object.

We take the following steps to make data available before rendering:

  1. Collect your graphql queries and run them to get their results.
  2. Process these results as store records.
  3. Pass these records to your app while rendering. App should create the environment using these records.
  4. Set your fetch policy to use the store during rendering. That means if Relay finds data in the store, that will be used for rendering. It will no longer wait for the network request and therefore won’t render the loading indicator.

Ideally, you should set fetch policy to store-or-network. In that case, Relay will use data in the store to render. If the required data is not present, Relay will send out a network request. In case you want to do initial rendering using store but update it later, use store-and-network. Here, Relay renders the app using its store. While it does that, it also sends a network request. When the network request comes in, Relay re-renders with the new data.

2. Data availability — the next.js way

Next.js has first class support for injecting external data into your application before it renders. But that requires you to write your components a certain way. You need to break your code into pages and each page should specify its data requirements in getStaticProps or getServerSideProps. If you are writing a new application, I suggest you go with it. This way is very efficient and also blends well with the next.js workflow.

An example of making Relay work that way is provided in the next.js repo. But for the sake of completeness, I will cover it here. Here is the basic idea:

  1. For each page that needs graphql data, write a query specifying its needs.
  2. In data fetching handler of the page, execute the query using fetchQuery.
  3. Take the records from the result of query and pass it to the page as props.
  4. Inside the page, use these records to create an environment. Use this environment for rendering.
  5. These records are always available with the page before rendering — on server side and on the client side.

Let us switch our example to this stage. To do so, get to the corresponding commit.

git checkout 42dc8e690b163c1c8c24d8a654fe105a663d670e

Now let us explore the changes. First off, take a look at the relay environment. The function now accepts an argument for records. The second thing that I have done is moved the RelayEnvironmentProvider from _app.tsx to my page. You can see that by checking the app page and the index page.

The most interesting part is the index page. I have done 3 changes here:

  1. Added a getStaticProps to run the graphql query.
  2. Refactored the page into a wrapper and its content.
  3. Added RelayEnvironmentProvider to the wrapper.

Now take a look at getStaticProps function. It creates a new store and runs the query. After the query is complete, it extracts all records from the store and passes them to the page as props. Page extracts these records from props and creates an environment using them. Note how much refactoring has gone into the page. If there are multiple pages, you have to do this for every page.

Let us see this in action. Before everything, check the server side markup. Disable Javascript and check out the development URL.

Server side rendered markup

This works as we wanted it to. Using this approach has two benefits:

  1. Data is available before the app renders. So you do not need any extra re-renders.
  2. The data is always available to the page before rendering. This means that even when the app hydrates on client side, there is no loading indicator.
No loading on hydration

The only downside here is that you have to write your components such that all the queries are available at the page level. If you are writing a new app, this is alright. But refactoring an old app can be a nightmare. And sometimes, you may not have control over all the components. For example, if some of the components are published as npm packages, you might not know anything about queries written inside them.

We attained data availability. But to do so, we sacrifice flexibility in structure. What if we wanted to retain the original application structure? What if we don’t want to refactor all our queries and keep everything as it was? The issue to that would be collecting all the queries. We don’t know where every query is. Some queries might be inside npm packages and would not be accessible to us directly. Thankfully, relay-tools collection has a solution just for this.

Relay network modern is a network layer for Relay environment. It comes with some very powerful middlewares that help you manage your network layer. Relay network modern SSR adds server side data collection support. Consider the barebones example. Our basic idea is this:

  1. Render your app on the server and dispose the output.
  2. Take the Relay environment used to render that app.
  3. If Relay network modern SSR was used, you can access all the queries that were fired during rendering via Relay environment.
  4. Wait for the queries to finish loading. Process the results as store records.
  5. Render your app again and pass these records to your app while rendering. App should now create the environment using these records.

As before, you need to set your fetch policy to use the store during rendering. During the first render, Relay will display a loading indicator as the store is empty. But during the second render, it will render the required data from the store.

3. Data availability — the universal way

Get to the commit. This is built on the barebones example.

git checkout 7ff350f85a5cd07c8fa2be9e497c9e80a60440c5

Now, I want you to look at the environment.ts file again. This is same as the previous environment, only rewritten using the relay-network-modern package.

This is where the magic happens. The custom document is used to enhance the HTML template for your page. It is only rendered on the server side. Your app is rendered and inserted into the template. If you have a static website, custom document is rendered during build time. If you have a SSR web app, it is rendered on every request. To force SSR rendering, you can add a getInitialProps to your custom app.

The key here is that custom document is where your app is rendered during SSR. Remember that it does not render here on the client side. Now that we know where the app is rendered, we can start working.

We will be rendering the app on server twice. First time, it will only be to collect all the queries for Relay. For this render, we will use a different environment. Take a moment to look at it. It is same as the normal environment but there are two differences:

  1. Store is empty initially — not created with any records
  2. We have added the relayServerSSR middleware to the network layer. This middleware will collect all the fired queries for us.

Purpose of this render is to simply collect all the fired queries. Let us go to our custom document. If you look at it, you will see that we have added the getInitialProps method to the document. This will run before document is rendered. Look carefully at the method

  1. Create the SSR customised environment with a relayServerSSR instance.
  2. Modify the page rendering by wrapping it in RelayEnvironmentProvider. That is done via ctx.renderPage.
  3. Fire up the page rendering via Document.getInitialProps(ctx) and wait for it to finish.
  4. Wait for all the queries to complete. That is done via await relayServerSSR.getCache().
  5. Records for all the queries fired are processed from the environment via env.getStore().getSource().toJSON()

Now we have all the records that we wanted. If you skip to the end of the getInitialProps method, you will see that we have passed these records to the document as props. The document the converts it to a base64 string and adds it to the DOM. You can see that happening in the document’s render method. That is done to make our Relay data available on the client side.

Now that we have the records, let us render our custom app. During the first render, we simply rendered the page with our SSR customised environment. Second render will generate the actual markup. Let us continue reading the getInitialProps method.

If we had rendered custom app during the first render, our page would have used the standard environment provided by the app and not our SSR customised environment as the former would be in a nearer context to the page.

As you can see in the getInitialProps method, we render the app and pass the records to it as props. That is it. Now our app has relay data present beforehand. There are no loading indicators.

During the first render, we skipped the app and directly rendered the Page. App will be used during the second server side render and on the client side renders. Let us take a quick look at the custom app. There are a few changes in the app:

  1. App now accepts an optional prop records. On the second server side render, custom app recieves these from the custom document. On client side, this prop is not present
  2. A useMemo hook is added. Here, we check if there are any records in the props. If they are not present, we look inside the DOM to find base64 encoded string that parses into records. If neither is present, we return an empty object.
  3. We create the environment using these records.

That is it. You can now write your queries and fragments as you like. This will handle all use cases.

  1. There are two renders on the server side and one render on the client side (during hydration).
  2. Our custom document is rendered only once on the server. Before rendering the custom document, we render our page and app using getInitialProps method of the custom document. This method runs before custom document is rendered.
  3. Our page is rendered thrice — directly during the first server side render and as a child of custom app during the second server side render and the client side render.
  4. Our custom app is rendered twice — during the second server side render and during the client side render.
  5. First render is only done for collecting queries. It servers no other purpose. Second render is for generating HTML markup with Relay data. Relay data is available with the app before the second server side render.
Server side generated markup : Page loaded with JS disabled

6. When document renders, we encode the Relay data in a base64 string and put in the HTML DOM. On client side, app extracts this data before rendering. Therefore, Relay data is available with the app before the page renders on the client side.

No loading indicator on hydration : Page loaded with JS enabled

7. This approach is more flexible but less efficient than the next.js way. This involves two renders on the server side while the next.js approach requires only one render.

Conclusion

This covers most of the Relay SSR discussion. While I use next.js to demonstrate, the approaches discussed here work almost everywhere. You can build up on this and work towards more advanced functionality such as passing cookies between client and the graphql server.

Did this article help you? Was it missing something? Can it use a bit of trimming? How could it be better? Let me know in the comments below ; )

CSE Undergrad | NIT Bhopal | All things Web

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store