One of the problems that you will have to solve when writing a Server Side rendering application (React) is working with the meta tags that every page should have, which help indexing them by search engines.

Starting to google, the first solution that you will be led to is most likely React Helmet.

One of the advantages of the library is that it can be considered isomorphic in some way and can be perfectly used both on the client side and on the server side.

class Page extends Component {
   render() {
       return (
           <div>
               <Helmet>
                   <title>Turbo Todo</title>
                   <meta name="theme-color" content="#008f68" />
               </Helmet>
               {/* ... */}
           </div>
       );
   }
}

On the server, the router will then look like this:

app.get('/*', (req, res) => {
  const html = renderToString(<App />);
  const helmet = Helmet.renderStatic();
  res.send(`
     <!doctype html>
     <html ${helmet.htmlAttributes.toString()}>
     <head>
       ${helmet.title.toString()}
       ${helmet.meta.toString()}
     </head>
     <body ${helmet.bodyAttributes.toString()}>
       <div id="app">${html}</div>
     </body>
     </html>
  `);
});

Both of these snippets are completely correct and efficient, but there is one BUT, the above code for the server is completely synchronous and therefore completely safe, but if it becomes asynchronous, it will hide difficultly debugged bugs in itself:

app.get('/*', async (req, res) => {
   // ....
   await anyAsyncAction();
   //....
   const helmet = Helmet.renderStatic();
   // ...
});

The problem here is primarily in the React Helmet library itself and, in particular, in that it collects all the tags inside the React Tree and puts it in fact into a global variable, and since the code has become asynchronous, the code can mix simultaneously processed requests from different users.

The good news is that a fork was made based on this library and now it is better to give preference to the react-helmet-async library. The main paradigm in it is that in this case, the react-helmet context will be isolated within the framework of a single request by encapsulating the React Tree application in HelmetProvider:


import { Helmet, HelmetProvider } from 'react-helmet-async';

app.get('/*', async (req, res) => {​
   // ... code may content any async actions
   const helmetContext = {};
   const app = (
       <HelmetProvider context={helmetContext}>
           <App/>
       </HelmetProvider>
   );
   // ...code may content any async actions
   const html = renderToString(app);
   const { helmet } = helmetContext;
   // ...code may content any async actions
});

This could be finished, but perhaps you will go further in an attempt to squeeze out maximum performance and improve some SEO metrics. For example, you can improve the Time To First Byte (TTFB) metric – when the server can send page layout with chunks as they are calculated, rather than waiting until it is fully calculated. To do this, you will begin to look towards using renderToNodeStream instead of renderToString.

Here we are again faced with a small problem. To get all the meta tags that a page needs, we must go through the entire app react tree, but the problem is that the meta tags must be sent before the moment we start streaming content using renderToNodeStream. In fact, we then need to compute the React Tree twice and it looks something like this:

app.get('/*', async (req, res) => {​
   const helmetContext = {};
   let app = (
       <HelmetProvider context={helmetContext}>
           <App/>
       </HelmetProvider>
   );

   // do a first pass render so that react-helmet-async
   // can see what meta tags to render
   ReactDOMServer.renderToString(app);
   const { helmet } = helmetContext;

   response.write(`
       <html>
       <head>
           ${helmet.title.toString()}​
           ${helmet.meta.toString()}
       </head>
       <body>
   `);

   const stream = ReactDOMServer.renderToNodeStream(app);
  
   stream.pipe(response, { end: false });
   stream.on('end', () => response.end('</body></html>'));
});

With such an approach, the need for such optimization becomes in principle a big question and it is unlikely that we will improve the TTFB metric we want to achieve.

Here we can play a little optimization and there are several options

instead of renderToString use renderToStaticMarkup, which probably will help to some extent win some time
instead of using the renderers offered by the react from the box, come up with your own light version of the passage through the react tree, for example, based on the react-tree-walker library, or refuse to completely render the tree and look only at the first level of the tree, not paying attention to the embedded components, so say shallow rendering
consider a caching system that might sometimes skip the first walk through the react tree

But in any case, everything described sounds too sophisticated and, in principle, casts doubt on this race for efficiency, when some abnormally complex architecture is built in a couple of milliseconds.

It seems to me in this case, for those who are familiar with how to extract data for rendering for the SSR (and if someone does not know, then this is an excellent article on this topic), we will help to go the same way of extracting meta tags for the page.

The general concept is that we have a configuration file for routers – this is an ordinary JS structure, which is an array of objects, each of which contains several fields of the type component, path. Based on the request url, we find the router and component associated with it from the configuration file. For these components, we define a set of static methods such as loadData and, for example, createMetatags for our meta tags.

Thus, the page component itself will become like this:

class ProductPage extends React.Component {
   static createMetatags(store, request){
       const item = selectItem(store, request.params.product_id);
       return []
           .concat({property: 'og:description', content: item.desc})
           .concat({property: 'og:title', content: item.title})
   }
   static loadData(store, request){
       // extract external data for SSR and return Promise
   }
   // the rest of component
}

We’ve defined a static createMetatags method that creates the required set of meta tags. With this in mind, the code on the server will become like this:

app.get('/*', async (req, res) => {​​
   const store = createStore();
   const matchedRoutes = matchRoutes(routes, request.path);

   // load app state
   await Promise.all(
       matchedRoutes.reduce((promises, { route }) => {
           return route.component.loadData ? promises.concat(route.component.loadData(store, req)) : promises;
       }, [])
   );
  
   // to get  metatags
   const metaTags = matchedRoutes.reduce((tags, {route}) => {
       return route.component.createMetatags ? tags.concat(route.component.createMetatags(store, req)): tags
   });

   res.write(`​
     <html>​
     <head>​
         ${ReactDOMServer.renderToString(() => metaTags.map(tag => <meta {...tag}/>) )}​​
     </head>​
     <body>​
 `);​

 const stream = ReactDOMServer.renderToNodeStream(app);​
 stream.pipe(response, { end: false });​
 stream.on('end', () => response.end('</body></html>'));​
});