For a project, I had the requirement of making the public URL of the application configurable via an environment variable that can be set before starting the Node.js express server.
The frontend of that application is built with create-react-app
which is very opinionated and has some limitations.
I will share with you those limitations and the workarounds for implementing such a feature.
The Defaults
create-react-app assumes that your application is hosted on the server root. E.g. the URL to your favicon in the build output index.html
file would look similar to this the following:
<link rel="shortcut icon" href="/favicon.ico"/>
In case you want to host your website under a relative part that is different from the server root there is an option for specifying the base URL either via the homepage
key inside your package.json
or the PUBLIC_URL
environment variable that must be set before building the project. When running the react-scripts build
script, the %PUBLIC_URL%
placeholders inside the index.html
file are replaced with the environment variable string.
In case we want to serve our application under a different public URL, such as https://my-site.com/app
, we can build the project like that:
PUBLIC_URL=https://my-site.com/app yarn react-scripts build
The contents of the build artifact index.html
have now changed:
<link rel="shortcut icon" href="https://my-site.com/app/favicon.ico"/>
The limitations
This method, however, has the drawback of requiring us to already know the public URL when building the frontend application.
As mentioned earlier our use-case requires setting the public URL dynamically, as the express server that is bundled as a binary and each user should be able to run that web server under a domain/path they specify.
The solution
The initial idea was to set PUBLIC_URL
to some string that could get replaced by the express web server. The Node.js script loads the index.html
file and replaces all the occurrences of the placeholder string:
"use strict";
const express = require("express");
const app = express();
const path = require("path");
const fs = require("fs");
const PUBLIC_PATH = path.resolve(__dirname, "build");
const PORT = parseInt(process.env.PORT || "3000", 10)
const PUBLIC_URL = process.env.PUBLIC_URL || `http://localhost:${PORT}`;
const indexHtml = path.join(PUBLIC_PATH, "index.html");
const indexHtmlContent = fs
.readFileSync(indexHtml, "utf-8")
.replace(/__PUBLIC_URL_PLACEHOLDER__/g, PUBLIC_URL);
app.get("/", (req, res) => {
res.send(indexHtmlContent);
});
app.use(express.static(path.join(PUBLIC_PATH)));
app.listen(PORT);
Now we can build our app like this:
PUBLIC_URL=__PUBLIC_URL_PLACEHOLDER__ yarn react-scripts build
However, this only solves linking the assets correctly. From an application point of view, we also need to figure out the application root path. Here is a quick example of our Image
component:
const Image = () =>
<img src={`${process.env.PUBLIC_URL}images/me.jpeg`} />
Because we specified PUBLIC_URL
being set to __PUBLIC_URL_PLACEHOLDER__
and the environment variable is also embedded inside the JavaScript bundles (and used for resolving asset paths) the server will now send requests to __PUBLIC_URL_PLACEHOLDER__/images/me.jpeg
๐
.
If we search for the string __PUBLIC_URL_PLACEHOLDER__
inside the build assets located at build/static/js
we can find multiple occurrences.
create-react-app injects an environment object inside the bundle that is similar to the Node.js process.env
object.
process.env = {
NODE_ENV: "production",
PUBLIC_URL: "__PUBLIC_URL_PLACEHOLDER__/"
}
In order to have a viable solution we also need to replace those occurrences on that object with the correct URL.
But parsing those .js
files while serving them and replacing the string with express is not a good option as we now need to do it either on each request or cache the file contents in memory or in a separate file.
After some thinking, I realized there is a better option available that would allow us to only replace the .js
content once post-build.
First, we add the following to our index.html
file:
<script>
window.__PUBLIC_URL__ = "";
</script>
Make sure to add it into the head of the document to ensure it is loaded/evaluated before our application .js
bundles.
Up next we must transform the process.env
definition to the following:
process.env = {
NODE_ENV: "production",
PUBLIC_URL: window.__PUBLIC_URL__ + "/"
}
We can achieve that by writing a script that will replace the occurrence of the __PUBLIC_URL_PLACEHOLDER__
string inside our build/static/js/*.js
files with window.__PUBLIC_URL__
. That script can be executed immediately after running yarn react-scripts build
.
I found a cool library replacestream, that allows replacing file contents while streaming it. This keeps the memory footprint low for bigger application bundles.
// scripts/patch-public-url.js
"use strict";
const fs = require("fs");
const path = require("path");
const replaceStream = require("replacestream");
const main = async () => {
const directory = path.join(__dirname, "..", "build", "static", "js");
const files = fs
.readdirSync(directory)
.filter(file => file.endsWith(".js"))
.map(fileName => path.join(directory, fileName));
for (const file of files) {
const tmpFile = `${file}.tmp`;
await new Promise((resolve, reject) => {
const stream = fs
.createReadStream(file)
.pipe(
replaceStream(
'"__PUBLIC_URL_PLACEHOLDER__"',
// the whitespace is added in order to prevent invalid code:
// returnwindow.__PUBLIC_URL__
" window.__PUBLIC_URL__ "
)
)
.pipe(
replaceStream(
'"__PUBLIC_URL_PLACEHOLDER__/"',
// the whitespace is added in order to prevent invalid code:
// returnwindow.__PUBLIC_URL__+"/"
' window.__PUBLIC_URL__+"/"'
)
)
.pipe(fs.createWriteStream(tmpFile));
stream.on("finish", resolve);
stream.on("error", reject);
});
fs.unlinkSync(file);
fs.copyFileSync(tmpFile, file);
fs.unlinkSync(tmpFile);
}
};
main().catch(err => {
console.error(err);
process.exitCode = 1;
});
Let's also replace the window.__PUBLIC_URL__
assignment inside our index.html
within the Node.js code.
"use strict";
const express = require("express");
const app = express();
const path = require("path");
const fs = require("fs-extra");
const PUBLIC_PATH = path.resolve(__dirname, "build");
const PORT = parseInt(process.env.PORT || "3000", 10)
const PUBLIC_URL = process.env.PUBLIC_URL || `http://localhost:${PORT}`;
const indexHtml = path.join(PUBLIC_PATH, "index.html");
const indexHtmlContent = fs
.readFileSync(indexHtml, "utf-8")
- .replace(/__PUBLIC_URL_PLACEHOLDER__/g, PUBLIC_URL);
+ .replace(/__PUBLIC_URL_PLACEHOLDER__/g, PUBLIC_URL)
+ .replace(/window\.__PUBLIC_URL__=""/, `window.__PUBLIC_URL__="${PUBLIC_URL}"`);
app.get("/", (req, res) => {
res.send(indexHtmlContent);
});
app.use(express.static(path.join(PUBLIC_PATH)));
app.listen(PORT);
Let's also adjust our build script inside the package.json
:
PUBLIC_URL=__PUBLIC_URL_PLACEHOLDER__ react-scripts build && node scripts/patch-public-url.js
Post-build, we can start our server like this:
PUBLIC_URL=http://my-site.com/app node server.js
Bonus ๐: NGINX Reverse Proxy Configuration
upstream app {
server localhost:3000;
}
server {
listen 80;
server_name my-site.com;
location /app {
rewrite ^/app(/.*)$ $1 break;
proxy_pass http://app/;
# We also sue WebSockets :)
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
}
}
Further note regarding service workers:
In case you inspected the build
folder and searched for __PUBLIC_URL_PLACEHOLDER__
, you probably noticed that there are also service-worker .js
files and also an asset-manifest.json
file that includes the given string. I currently do not care about those, because our application has no offline mode. If you consider this, you will probably have to make some more string replacements.
Furthermore, since we are exclusively using CSS in JS I did not do any CSS string replacements. If you do so and use the url()
you might also need to adjust your CSS files.
We have finished ๐.
Do you have anything to add to that method, found a typo or got a better method for doing the same thing? Drop your comment and start a discussion below โฌ
Thank you so much for reading!
Top comments (3)
Have you considered using relative URLs?
PUBLIC_URL=.
Results in URLs like "./favicon.ico"
Then you can deploy your app to any folder, like /app, or /some/arbitrary/folder
dev-to-uploads.s3.amazonaws.com/i/...
Yeah, I have considered using relative URLs but I think absolute URLs are more "clean".
My main points are that they are a bit confusing and harder to reason about.
Also, it seems like it would not work that well with client-side routing. Without the
PUBLIC_URL
we cannot find out what part of the path is the base, but I might be wrong here!Do you have an example of client-side routing with a relative URL?
This one is gold!
Thank you so much for this article, Laurin.
I wanted to create a React admin interface for a WordPress plugin and faced exactly the same issue. Works like a charm now <3