Using renderToNodeStream with react-helmet

Viewed 4106

I am currently creating a website and have been looking to start using renderToNodeStream to increase the performance of the server side rendering instead of using renderToString.

At the moment I am using renderToString and then using Helmet.renderStatic to get all the required meta data and title from each of the pages. When I switch to using renderToNodeStream however, I will be writing to the head prior to rendering anything, and so can no longer use Helmet.renderStatic anymore.

I was thinking I could do the below to solve this issue, but this involves first using renderToString before then using renderToNodeStream, and probably doesn't really provide much improvement...

app.use('*', (req, res) {
  Loadable.preloadAll().then(() => {
    const store = createStore(
      reducers,
      getDefaultStateFromProps(),
      applyMiddleware(thunk)
    );
    const routeContext = {};
    const router = (
      <Provider store={store}>
        <StaticRouter location={req.url} context={routeContext}>
          <App/>
        </StaticRouter>
      </Provider>
    );

    res.setHeader('Content-Type', 'text/html');

    renderToString(router);
    const helmet = Helmet.renderStatic();

    res.locals.title = helmet.title;
    res.locals.meta = helmet.meta;
    res.locals.link = helmet.link;

    res.write(headTemplate(res.locals));

    const stream = renderToNodeStream(router);

    stream.pipe(res, { end: false });
    stream.on('end', () => {
      res.locals.context = JSON.stringify(store.getState());
      res.end(bodyTemplate(res.locals));
    });
  });
}

Does anyone know how to get around this issue?

1 Answers

This has been an issue I have also been struggling with lately -- I've been trying to migrate an app from renderToString to renderToNodeStream and was having a heck of a time trying to get dynamic head data to work.

So, unfortunately react-helmet doesn't provide out of the box support for renderToNodeStream. There are two libraries that I know of that you can use. Check out:

  • react-helmet-async*
  • react-safety-helmet

*Though the documentation for react-helmet-async has a quick guide on how to use the library with renderToNodeStream, the author recently said it's not officially supported (@see https://github.com/staylor/react-helmet-async/issues/37#issuecomment-573361267)

Additionally, I see the Loadable.preloadAll function call - you're going to also have to migrate over to loadable-components which supports renderToNodeStream.

So, assuming you migrate over to loadable-components and one of the helmet libraries above, if your Head / helmet data is static, I believe everything should work for you right out of the box. If you have head data that depends on API calls, you might want to look into adding something like react-ssr-prepass.

I personally ended up using react-safety-helmet; here's the basic approach I took:

client

import { loadableReady } from '@loadable/component';
import { createHelmetStore, HelmetProvider } from 'react-safety-helmet';

const helmetStore = createHelmetStore();

loadableReady(() => {
  const root = document.getElementById('app-root')
  hydrate(
    <HelmetProvider store={helmetStore}
        <Provider store={store}>
            <StaticRouter location={req.url} context={routeContext}>
                <App/>
            </StaticRouter>
        </Provider>
    </HelmetProvider>, root)
})

server

import { renderToNodeStream } from 'react-dom/server';
import { ChunkExtractor } from '@loadable/server';
import { createHelmetStore, HelmetProvider } from 'react-safety-helmet';

const statsFile = path.resolve('../dist/loadable-stats.json')

const extractor = new ChunkExtractor({ statsFile })

new Promise((resolve, reject) => {
    const helmetStore = createHelmetStore();
    let body = '';
    const router = (
      <HelmetProvider store={helmetStore}>
            <Provider store={store}>
                <StaticRouter location={req.url} context={routeContext}>
                    <App/>
                </StaticRouter>
            </Provider>
        </HelmetProvider>,
    );
    renderToNodeStream(router)
      .on('data', (chunk) => {
        body += chunk;
      })
      .on('error', (err) => {
        reject(err);
      })
      .on('end', () => {
        resolve({
          body,
          helmet: helmetStore.renderStatic(),
        });
      });
}).then(({body, helmet}) => {
    // Create html with body and helmet object
    const linkTags = extractor.getLinkTags();
    const styleTags = extractor.getStyleTags();

    // This will be dependent on your implementation
    const htmlStates = {
        helmet, // From the resolved promise above
        store: store.getState(),
        linksTags,
        styleTags
    };

    const [startHtml, endHtml] = htmlTemplate(htmlStates); // Will vary on your implementation

    res.write(startHtml);
    res.write(body) // From the resolved promise above
    res.end(`${extractor.getScriptTags()}${endHtml}`) // This will vary as well - just make sure to add your JS tags before the closing </body></html>
});

Hopefully this helps get you on the right path. Best of luck.

Related