Create React App (CRA) offers quite a few advantages when it comes to mocking up quickly a react web application. One of the disadvantages however is that if you want to have your open graph and meta tags populated dynamically some sort of server-side rendering approach is usually recommended. That however is not always the desired route nor the most trivial to set up.
In this article, we would explore a solution where we would make our index.html
a handlebars template and hydrate it on the client site without SSR (Server-Side Rendering). This approach would use the npm scripts
section of our package.json where we would replace the start
and add prebuild
stages with simple bash scripts. That way by doing npm start
to start our app we would be hydrating and creating the index.html
at run and build time. Also, this approach would allow for multiple environments and instances configuration. You would be able to dynamically hydrate your index for development vs. production build or siteA vs. siteB instance by simply providing create react app with a few environment variables.
TLDR: Working version of the CRA below can be found on Repl.it. Fork it to run the npm scripts etc.
Requirements:
- Nodejs version 8.10 or above
- npm version 5.6 or above
Setting up our React App
So lets start with creating our CRA first which we would name dynamic-meta
with:
npx create-react-app dynamic-meta
npx
on the first line is not a typo — it’s a package runner tool that comes with npm 5.2+
Next, let’s add the handlebars CLI package called hbs-cli
and save it to our package.json
file as a dependency:
npm i hbs-cli -s
We need the hbs-cli
in order to run handlebars via the shell script which we would place in our package.json scripts
section.
At this point, we have all the needed packages from npm to make this work. Next, let’s setup a few more files and folders for organization purposes. Let’s move the index.html
from the public folder to a folder in the root called template
and rename it to index.hbs
. This would be our template which would be hydrated to produce the final index.html
in the public folder. We would also add some handlebars variables and clean up some of the comments added by CRA. So the end result is something like this:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta property="og:title" content="{{{TITLE}}}"/>
<meta property="og:description" content="{{{DESCRIPTION}}}"/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<title>{{{TITLE}}}</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>
You can already see what we would be replacing in the index template. All the handlebars variables shown above would be hydrated via json files at start/build time. To learn more about handlebars click here.
Now we would assume that we have few environments for our application and few instances as well. For example, we will have a development environment and a production one and in each of those, we might have different API and other keys that we might want to use. You might have a sandbox API key for a 3rd party integration in development and another production version for your production environment. Also, each one of the different instances might have a different favicon.ico and manifest.json as well. What about the app logo?
So in order to support all those we would simply create another folder in the root and call it sites. In that folder, we would have sub-folders with the name of our instances. For this example, we would use siteA
and siteB
. And since we are creating folders, let’s create another one called scripts in the root folder.
At this point, our app tree looks like this:
root
|
└-- public // CRA default public folder
└-- scripts // Our start
and prebuild
scripts reside here
└-- sites // To keep our instance folders organized
| |
| └-- siteA // Instance folder A
| └-- siteB // Instance folder B
└-- src // CRA default source folder
└-- template // Where our index.hbs resides
|
└-- index.hbs // Our handlebars template file
Since each instance of our site would be different in terms of look and feel we would copy favicon.ico, logo files, and manifest.json to each of the instance folders and remove them from the public folder. Our public folder should contain only the rebots.txt
file generated by the CRA at this point. We would copy all the instance files from the instance folder to the public folder at start & build time. We will also add an entry to our .gitignore
file to ignore all files in the public folder except the robots.txt one later.
The configuration JSON files
We would also want each site instance to have its own images/title/description/API Keys etc. So let’s start with what would be common between all the siteA environments. Title and Description sound like good candidates here. Let’s create a default.json file that would be always used by handlebars when we want to hydrate our index.hbs. For siteA we would have:
{
"TITLE": "SITE A",
"DESCRIPTION": "SITE A Description"
}
You would have the same name file in the siteB folder with different values for the TITLE and DESCRIPTION.
Ok we are almost there. The only thing missing is the actual scripts to “glue” together everything.
The Scripts
If you try to run our app at its current state nothing good would happen since we have no index.html at all in our public folder. We need to actually generate one. To do this let’s set up our scripts in our package.json first:
"scripts": {
"start": "bash scripts/start.sh",
"prebuild": "bash scripts/prebuild.sh",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
Next, let’s create the prebuild.sh file in the scripts folder. We start with that one since the start.sh would be relying on the prebuild.sh. Here is what the prebuild.sh would look like:
#!/usr/bin/env bash
# Parameters
ENV=${REACT_APP_NODE_ENV:-development}
INSTANCE=${REACT_APP_NODE_APP_INSTANCE:-siteA}
# Folders
PUB="public"
SITES="sites"
# Files containing the handlebars variables
DEFAULT_FILE=${PUB}/default.json
ENV_FILE=${PUB}/${ENV}.json
# Template files as parameters to the CLI command
PARAMS="-D ${DEFAULT_FILE}"
echo "ENV: $ENV"
echo "INSTANCE: $INSTANCE"
echo
# Copy all instance files into the public web folder
[ -d ${SITES}/${INSTANCE} ] && command cp ${SITES}/${INSTANCE}/* ${PUB}
# Only continue if we have default template variables file
if [ -f ${DEFAULT_FILE} ]
then
# Concatenate env variables file to the data parameters variable
[ -f ${ENV_FILE} ] && PARAMS="${PARAMS} -D ${ENV_FILE}"
# Run the CLI for handlebars to hydrate the template with the data files
command npx hbs ${PARAMS} template/index.hbs -o ${PUB}
# Remove the copied template json files except manifest.json
command find ${PUB}/*.json ! -name manifest.json | xargs rm
fi
echo "┌-----------------┐"
echo "| Pre-Build Done! |"
echo "└-----------------┘"
Now we would not go into too many details of what the shell script above does (there are comments on every line) but here are few pointers. The first and most important thing to mention here is the parameters being read in ENV and INSTANCE variables. If any is provided then we would use those but if not we would default to development
environment and siteA
instance.
Note that we use the command
prefix in order to make sure we avoid any aliases being used. For example, if the alias of cp -i is registered for the cp command the script would have different behavior. Another important thing to more here is the use of npx hbs
in order to hydrate our index.hbs template and produce the index.html file. Handlebars by default outputs HTML so we only specify the output folder with the -o parameter.
Next lets setup our final script file – the start.sh:
#!/usr/bin/env bash
export REACT_APP_NODE_ENV="${REACT_APP_NODE_ENV:-development}"
export REACT_APP_NODE_APP_INSTANCE="${REACT_APP_NODE_APP_INSTANCE:-siteA}"
# Run the prebuild with the exported above ENV variables
bash scripts/prebuild.sh
# Start the app
npx react-scripts start
As you can see the only purpose of the start.sh is to export the REACT_APP variables so they are accessible to the prebuild.sh file. After that, it simply start the app via the react-scripts start command. Note also that it defaults the REACT_APP_NODE_ENV to development
and REACT_APP_NODE_APP_INSTANCE to siteA
. This is for convenience purposes since we do not want to always specify the CRA environment variables to start our app. This way npm start
would start the development
siteA.
Wrapping things up
So if you start our app right now with npm start … this is what we should see in the terminal:
ENV: development
INSTANCE: siteA
Wrote /<PATH TO YOUR APP>/dynamic-meta/public/index.html from template/index.hbs
┌-----------------┐
| Pre-Build Done! |
└-----------------┘
Opening the page source of the dynamically created index.html in the public folder gives us:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta property="og:title" content="SITE A"/>
<meta property="og:description" content="SITE A Description"/>
<link rel="apple-touch-icon" href="/logo192.png" />
<link rel="manifest" href="/manifest.json" />
<title>SITE A</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script src="/static/js/bundle.js"></script><script src="/static/js/0.chunk.js"></script><script src="/static/js/main.chunk.js"></script></body>
</html>
As you can see we have our handlebars variables hydrated. Now lets try passing a different instance with:
REACT_APP_NODE_APP_INSTANCE=siteB npm start
We would get as we would expect the siteB meta title and description:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta property="og:title" content="SITE B"/>
<meta property="og:description" content="SITE B Description"/>
<link rel="apple-touch-icon" href="/logo192.png" />
<link rel="manifest" href="/manifest.json" />
<title>SITE B</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script src="/static/js/bundle.js"></script><script src="/static/js/0.chunk.js"></script><script src="/static/js/main.chunk.js"></script></body>
</html>
More than just meta tags
Last but not least what about different values that we might have for environments like development or production? Let’s say we have a 3rd party app integration like google analytics or similar that has different keys for the different environments. How would we solve that with the approach above?
We simply add them in the index.hbs
with handlebars if condition and we add the environment json files. Our prebuild script already accounts for that. So let’s add development.json and production.json in the siteA and siteB site folders. In each let’s add a prop with the name APP_ID. For example, this would be our sites/siteA/development.json:
{
"APP_ID": "DEV SITE A"
}
and this sites/siteA/production.json:
{
"APP_ID": "PROD SITE A"
}
Now lets add in the index.hbs template:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta property="og:title" content="{{{TITLE}}}"/>
<meta property="og:description" content="{{{DESCRIPTION}}}"/>
<meta name="description" content="{{{DESCRIPTION}}}"/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<title>{{{TITLE}}}</title>
<script type="text/javascript">
{{# if APP_ID}}window.APP_ID='{{APP_ID}}';{{/if}}
if (window.APP_ID) {
alert(`APP_ID: ${window.APP_ID}`)
}
</script>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>
We simply use handlebars if condition which says if we have as a variable APP_ID then write the text window.APP_ID='{{{APP_ID}}’ and replace the APP_ID with the value. Then we check if window.APP_ID exists and if so we alert it. So if we run this with the default npm start
we would see the alert DEV SITE A since we have an entry for the APP_ID in our siteA
development.json file.
Now lets run the production one with:
REACT_APP_NODE_ENV=production REACT_APP_NODE_APP_INSTANCE=siteA npm start
Now we would be using the the APP_ID from the production.json file and we would get PROD SITE A alert.
What about build time?
If you would like to crate a build for the default siteA
instance and the default development
environment when you simply run:
npm run build
To run a production build for siteB you would run:
REACT_APP_NODE_ENV=production REACT_APP_NODE_APP_INSTANCE=siteB npm run build
The cool thing is that we have hooked our dynamic index.html creation at the prebuild time so the actual build would already have the hydrated index.html from the index.hbs and would then go through the create react app react-scripts pipeline to create the final create react app build.
Repl.it
If you would like to play with the code for this and see the actual results from the create react app just go to this link. Then click on the Run button to start the default build. You can also then use the Repl.it console to execute the different npm start commands we mentioned above where you would pass the REACT_APP_NODE_ENV and the REACT_APP_NODE_APP_INSTANCE parameters.
Other Options
You can achieve a similar result without the use of the hbs-cli
package and only with shell. For that, you can use the Mustache templates in bash or mo for short and call that instead of hbs-cli in your prebuild.sh script. You might have to change the parameters passed to mo slightly but other than that the rest would remain the same.
Add comment