How to Add Dynamic Meta Tags Server Side with Create React App

How to Add Dynamic Meta Tags Server Side with Create React App

This issue in the create-react-app repo has been getting a lot of attention recently, so I thought I'd write a brief tutorial with an example of how to add dynamic metadata tags server side with React. Want to skip right to the code? Check out the demo repo.

In this tutorial I'll cover how to achieve this using a basic Create React App (CRA) build and a Node server. If you're using a different server, your syntax will change, but the basic idea is the same.

This is the strategy that we use at Kapwing, an online video editor and meme maker. We have many different tools, so for each tool we need to update the metatags in a simple way.

The problem

You're managing a web application built with React and served from a Node server. You have many routes on your application, such as an /about page, or /post/:id pages where each post has different content. For each route, you want to set different meta tags, like the title, description, OG image, and other such things.

You might have used something like React Helmet to manage your meta tags within your React code. But the problem is that when a page or post gets shared to Facebook or Twitter, the crawlers don't run the the JavaScript on your site; they simply take the metatags from the initial bundle.

This won't do, because you obviously don't want to have the same metatags for every page on the entire application. So, you need to dynamically set the meta tags server side, so that the correct previews get shown.

Does this sound like you? Then keep reading!

Setup

For completeness sake, I'll start from scratch with Node and CRA, but feel free to skip this section if your project is already set up.

We'll create a react app (without a server yet) and work on it locally.

npx create-react-app my-app
cd my-app
npm start

Now we'll add react-router and create a few routes for demo purposes.

npm install react-router-dom

Now in our React project, we'll create a few sample routes to illustrate how dynamic routing might work. Let's create a home page, an about page, and a contact page. This would be the code for App.jsx:

import React from 'react'
import {
  BrowserRouter as Router,
  Route,
  Link
} from 'react-router-dom'

const Home = () => (
  <div>
    <h2>Home</h2>
    <p>
      This is the home page!
    </p>
  </div>
)

const About = () => (
  <div>
    <h2>About</h2>
    <p>
      This is the about page!
    </p>
  </div>
)

const Contact = ({ match }) => (
  <div>
    <h2>Contact</h2>
    <p>
      This is the contact us page!
    </p>
  </div>
)

const App = () => (
  <Router>
    <div>
      <ul>
        <li><Link to="/">Home</Link></li>
        <li><Link to="/about">About</Link></li>
        <li><Link to="/contact">Contact</Link></li>
      </ul>

      <hr/>

      <Route exact path="/" component={Home}/>
      <Route path="/about" component={About}/>
      <Route path="/contact" component={Contact}/>
    </div>
  </Router>
)
export default App;

All of these routes will have the same title and metatags, which are defined in public/index.html. More on this later.

For now we also need to set up our server to serve the CRA bundle. We can run npm run build to create a bundle, the we'll create a file called server.js. This will simply serve the created bundle and associated static files.

// in my-app/server.js
const app = express();
const port = process.env.PORT || 5000;
const path = require('path');

app.use(express.static(path.resolve(__dirname, './build')));

app.get('*', function(request, response) {
  const filePath = path.resolve(__dirname, './build', 'index.html');
  response.sendFile(filePath);
});

app.listen(port, () => console.log(`Listening on port ${port}`));

Now we can run node server.js, and if we visit localhost:5000 in the browser, we will get the server rendered bundle.

Finally, let's take a look at the my-app/public/index.html file:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <meta name="theme-color" content="#000000">
    <!--
      manifest.json provides metadata used when your web app is added to the
      homescreen on Android. See https://developers.google.com/web/fundamentals/engage-and-retain/web-app-manifest/
    -->
    <link rel="manifest" href="%PUBLIC_URL%/manifest.json">
    <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
    <!--
      Notice the use of %PUBLIC_URL% in the tags above.
      It will be replaced with the URL of the `public` folder during the build.
      Only files inside the `public` folder can be referenced from the HTML.

      Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
      work correctly both with client-side routing and a non-root public URL.
      Learn how to configure a non-root public URL by running `npm run build`.
    -->
    <title>This title applies for all pages on the site</title>
    <meta name="description" content="This description is static." />
    <meta property="og:title" content="Static OG title." />
    <meta property="og:description" content="Static OG description" />

  </head>
  <body>
    <noscript>
      You need to enable JavaScript to run this app.
    </noscript>
    <div id="root"></div>
    <!--
      This HTML file is a template.
      If you open it directly in the browser, you will see an empty page.

      You can add webfonts, meta tags, or analytics to this file.
      The build step will place the bundled scripts into the <body> tag.

      To begin the development, run `npm start` or `yarn start`.
      To create a production bundle, use `npm run build` or `yarn build`.
    -->
  </body>
</html>

As noted in the file, we can add meta tags to the index.html file, but the problem is that these meta tags will apply for every route on the whole site! So now we need to make them more dynamic.

The Solution

At a high level, we just need to do two things:

  1. Replace metatags in public/index.html that should be dynamic with a unique string, so that they can be identified and replaced server side

  2. For routes on the server, read in build/index.html then replace these strings with the dynamically set tags before we serve the page.

Step 1

We can replace the metatags in public/index.html with a unique string. Here's what I did:

...
<!-- in public/index.html -->
<title>$OG_TITLE</title>
<meta name="description"        content="$OG_DESCRIPTION" />
<meta property="og:title"       content="$OG_TITLE" />
<meta property="og:description" content="$OG_DESCRIPTION" />
<meta property="og:image"       content="$OG_IMAGE" />
...

Don't forget to run npm run build to generate your new bundle after you've changed the relevant tags to be a unique string.

Step 2

Now in server.js, we'll handle each route separately. For each route that we want to handle, we'll read in the index.html file, and replace the unique string that we set originally.

In Node, we can use the fs package to read and parse strings within each file. My server.js file now looks something like this:

const express = require('express');
const app = express();
const port = process.env.PORT || 5000;
const path = require('path');
const fs = require('fs')

app.get('/', function(request, response) {
  console.log('Home page visited!');
  const filePath = path.resolve(__dirname, './build', 'index.html');

  // read in the index.html file
  fs.readFile(filePath, 'utf8', function (err,data) {
    if (err) {
      return console.log(err);
    }
    
    // replace the special strings with server generated strings
    data = data.replace(/\$OG_TITLE/g, 'Home Page');
    data = data.replace(/\$OG_DESCRIPTION/g, "Home page description");
    result = data.replace(/\$OG_IMAGE/g, 'https://i.imgur.com/V7irMl8.png');
    response.send(result);
  });
});

app.get('/about', function(request, response) {
  console.log('About page visited!');
  const filePath = path.resolve(__dirname, './build', 'index.html')
  fs.readFile(filePath, 'utf8', function (err,data) {
    if (err) {
      return console.log(err);
    }
    data = data.replace(/\$OG_TITLE/g, 'About Page');
    data = data.replace(/\$OG_DESCRIPTION/g, "About page description");
    result = data.replace(/\$OG_IMAGE/g, 'https://i.imgur.com/V7irMl8.png');
    response.send(result);
  });
});

app.get('/contact', function(request, response) {
  console.log('Contact page visited!');
  const filePath = path.resolve(__dirname, './build', 'index.html')
  fs.readFile(filePath, 'utf8', function (err,data) {
    if (err) {
      return console.log(err);
    }
    data = data.replace(/\$OG_TITLE/g, 'Contact Page');
    data = data.replace(/\$OG_DESCRIPTION/g, "Contact page description");
    result = data.replace(/\$OG_IMAGE/g, 'https://i.imgur.com/V7irMl8.png');
    response.send(result);
  });
});

app.use(express.static(path.resolve(__dirname, './build')));

app.get('*', function(request, response) {
  const filePath = path.resolve(__dirname, './build', 'index.html');
  response.sendFile(filePath);
});

app.listen(port, () => console.log(`Listening on port ${port}`));

Now we can run node server.js, then visit the server side generated localhost:5000 in the browser. If you right click, and hit "view source", you will see the dynamically generated metatags for each of the pages.

That's it! I added a repo with the full code here: https://github.com/justswim/cra-metatag-demo, in case you would like to see the full demo.

Final notes

You may run into some issues while testing because of local caching. Try testing in an incognito window, to make sure that a new server request is issued each time.

Expanding this example for dynamic routes such as /post/:id would also be straightforward, as long as you read in the relevant route family on the server.

Finally, don't forget to use React-Helmet or some other document head manager for your client side code too, or else your application visitors will be stuck with the same metatag for their entire time on your website!

I hope this is helpful! More questions? Feedback? Happy to discuss on Twitter: @realericlu.

Create content faster with Kapwing's online video editor →