loading...
Cover image for Configure the create-react-app public URL post-build with Node.js and express

Configure the create-react-app public URL post-build with Node.js and express

n1ru4l profile image Laurin Quast ・5 min read

Cover Art by Joshua Welch

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!

Discussion

pic
Editor guide
 

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?