Premise

We require frontend-facing React app with server-side rendering.

Our React app is supported by a backend service written in language other than typescript or javascript, for example Java.

Since we require server-side rendering for the React app, we need to serve it via a Node.js server. Therefore we need to produce 2 Docker images:

  • the react app served by Node.js for purposes of server-side rendering
  • the backend app (for example a Springboot app served by an embedded Tomcat)

Each app runs in a separate Docker container and each will have a different hostname and/or port. Since the hostnames and ports may differ, we are likely to face issues with cross-domain XHR requests. To avoid these issues, we will want to proxy all XHR requests from browser to the backend app via the Node.js server.

The Problem

For accessing some of the API endpoints, we need to authenticate with a token. To prevent the browser code from accessing the token and thus mitigate cross-site scripting attacks, we store the token in a cookie that is set-up with HttpOnly flag. This prevents javascript code from accessing the cookie value, but sends the cookie with each XHR request to the server.

When making requests from browser, we can rely on browser itself to send this cookie with each request and to update the cookie should it change.

During server-side rendering, we have to ensure the token is sent as well. Node.js server makes requests to backend app via the isomorphic-fetch library. Contrary to browser implementation, the server-side code does not send or persist cookies.

The Simple Solution

Do not use server-side rendering if you do not have to. The browser will take care of saving, updating and sending the cookie.

I am not saying never to use server-side rendering. It speeds up page load and improves user experience. But if you do not need it, it will save yourself a lot of headache.

In addition to that, you will be able to get rid of one of the Docker containers mentioned above and just serve the built client bundle (javascript, css, static assets, skeleton index.html) via a single backend container

The Sophisticated One

Even if we decide to implement server-side rendering, we will still rely on browser functionality for client-side rendering.

However, for server-side rendering we need to implement the functionality ourselves. When the browser requests a page that is server-side rendered, we need to:

  • read and store the API token from cookie
  • manually send the token in cookie for each request
  • update stored API token if it was changed (if the API replied with Set-Cookie header)
  • if the API token changed, return Set-Cookie header to browser with new cookie

Initial Setup

We will be using the following libraries:

  • react & react-dom (obviously)
  • hapi.js for server-side rendering
  • redux & react-redux for keeping the state
  • redux-saga for handling side effects
  • isomorphic-fetch for server-side XHR requests

Setup the hapi to serve pages:

const server = new Server();

server.connection({
    address: "0.0.0.0",
    port: 3333,
    state: {
        ignoreErrors: true  // we will ignore incorrect cookies (otherwise incorrect cookie will cause server error)
    }
});

server.state("token", {
    ttl: 24 * 60 * 60 * 1000,   // valid for 1 day
    isSecure: true,
    isHttpOnly: true,
    isSameSite: "Strict",
    encoding: "none",
    path: "/",
    clearInvalid: true,         // remove invalid cookies
    strictHeader: true          // don't allow violations of RFC 6265
});

server.route({
    method: ["GET", "POST"],
    path: "/{params*}",
    handler: (request, reply) => {

        const sagaMiddleware = createSagaMiddleware();

        const store = createStore(mainReducer, {}, applyMiddleware([sagaMiddleware]));

        // render the react root component to dispatch all events and start side-effects (sagas)
        renderToString(<App />);

        store.dispatch({type: "@@redux-saga/CHANNEL_END"});

        // once all asynchronous side-effects are completed, server content to client
        rootSagaTask.done.then(() => {
            const reactString = renderToString(<App />);    // render the react root component again, this time we will return it to client
            reply(`<html>
...
<body>
<div id="app">${reactString}</div>
<script>window.__INITIAL_STATE__ = ${JSON.stringify(store.getState())};</script>
</body>
</html>`);
        });
    }
});

We will also need a reducer to store our token during server-side rendering:

const mainReducer = combineReducers({
    // ...
    token: (state: string = "", {type, payload}: Action<string>) => {
        return type === "SET_TOKEN" && payload ? payload : state;
    }
});

Reading and Storing the Cookie

We will read the API token from a cookie that is sent by the browser when making the page request.

The token needs to be stored in context of the request to ensure the value does not bleed over across different requests. For this reason, we will keep it in redux store for sake of simplicity.

In the handler method, before we renderToString for the first time, we dispatch action with token value:

store.dispatch({
    type: "SET_TOKEN", 
    payload: request.state.Token     // cookie value could be undefined
});

Note: My first idea was to store the token in global which would cause much less overhead, however the global is shared across all requests. Client-side window is a separate scope for each client (because each one is using a separate browser), however global is scope for the whole javascript file running via node. The file runs only once and handles requests of multiple clients.

Making API Requests

When making API call during server-side rendering, we will read the token from store and manually send it with request.

After server replies, we check if Set-Cookie header is set. If it is, we parse the token out of cookie and update the value in redux store.

We will use saga generator function for making API request.

const getToken = (state) => state.token;  // selector for getting API token from store

function* loadData() {

    if (isServer) {     // when making server-side request we need to handle the API token ourselves
        const token = yield select(getToken);

        const response = yield fetch("/api/data", {
            headers: {
                cookie: `Token=${token}`
            }
        });

        const cookies = parseCookie(response.headers.get("set-cookie"));
        if (cookies.Token) {
            store.dispatch({
                type: "SET_TOKEN", 
                payload: cookies.Token
            });
        }

        return response;
    } else  {   // for client-side request in browser, we do not have to handle anything, browser does it for us
        return yield fetch("/api/data");
    }
}

Rendering the Page

After server-side rendering is done, we need to check if the token changed during server-side rendering. If it did, we will set the cookie to update the value in browser.

We also have to ensure the token will not be sent to client in initial state. We are putting it into cookie to protect it from being accessible from client-side javascript code.

Before we renderToString for second time, we need to clear the state:

const token = store.getState().token;

store.dispatch({
    type: "SET_TOKEN", 
    payload: ""
});

if (token && token !== request.state.token) {
    reply.state("Token", Token);
}

That's It

This is all you need to make correct XHR requests during server-side rendering and persist the token.