Skip to content

Deploying Multiple Apps From a Monorepo to GitHub Pages

Deploying Multiple Apps From a Monorepo to GitHub Pages

Deploying Multiple Apps from a Monorepo to GitHub Pages

When it comes to deploying static sites, GitHub Pages is a popular solution thanks to being free and easy to set up in CI. The thing is, however, while it's perfectly suited for hosting a single application, such as a demo of your library, it does not support hosting multiple applications out of the box. It kind of just expects you to have a single app in your repository.

It just so happened I ended up with a project that originally had a single app deployed to GitHub Pages via a GitHub Actions workflow, and I had to extend it to be a monorepo with multiple apps. Once a second app was deploy-worthy, I had to figure out how to deploy it to GitHub Pages as well. As I found myself struggling a little bit while figuring out the best way to do it, I decided to write this post to share my experience and hopefully help someone else with a similar problem.

The Initial Setup

Initially, the project had a GitHub Actions workflow to test, build, and deploy the single app to GitHub Pages. The configuration looked something like this:

name: Build and deploy static content to Pages

on:
  # Runs on pushes targeting the default branch
  push:
    branches: ['main']

  # Allows you to run this workflow manually from the Actions tab
  workflow_dispatch:

# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
permissions:
  contents: read
  pages: write
  id-token: write

# Allow one concurrent deployment
concurrency:
  group: 'pages'
  cancel-in-progress: true

jobs:
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v3
      - name: Instal deps and run unit tests
        run: |
          npm ci
          npm run test

  deploy:
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    runs-on: ubuntu-latest
    needs: [unit-tests]
    steps:
      - name: Checkout
        uses: actions/checkout@v3
      - name: Build
        run: |
          npm ci
          npm run build
      - name: Setup Pages
        uses: actions/configure-pages@v2
      - name: Upload artifact
        uses: actions/upload-pages-artifact@v1
        with:
          path: 'dist/my-app'
      - name: Deploy to GitHub Pages
        id: deployment
        uses: actions/deploy-pages@v1

The URL structure for GitHub Pages is [your-organization-name].github.io/[your-repo-name], which means on a merge to the main branch, this action deployed my app to thisdot.github.io/my-repo.

Accommodating Multiple Apps

As I converted the repository to an Nx monorepo and eventually developed the second application, I needed to deploy it to GitHub Pages too.

I researched some options and found a solution to deploy the apps as subdirectories. In the end, the changes to the workflow were not very drastic. As Nx was now building my apps into the dist/apps folder alongside each other, I just had to update the build step to build both apps and the upload step to upload the dist/apps directory instead of the dist/my-app directory. The final workflow at this point looked like this:

name: Build and deploy static content to Pages

on:
  # Runs on pushes targeting the default branch
  push:
    branches: ['main']

  # Allows you to run this workflow manually from the Actions tab
  workflow_dispatch:

# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
permissions:
  contents: read
  pages: write
  id-token: write

# Allow one concurrent deployment
concurrency:
  group: 'pages'
  cancel-in-progress: true

jobs:
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v3
      - name: Install dependencies and run unit tests
        run: |
          npm ci
          nx run app1:test
          nx run app2:test

  deploy:
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    runs-on: ubuntu-latest
    needs: [unit-tests]
    steps:
      - name: Checkout
        uses: actions/checkout@v3
      - name: Install dependencies
        run: npm ci
      - name: Build app1
        run: |
          nx run app1:build
      - name: Build app2
        run: |
          nx run app2:build
      - name: Upload artifact
        uses: actions/upload-pages-artifact@v1
        with:
          path: 'dist/apps'
      - name: Setup Pages
        uses: actions/configure-pages@v2
      - name: Deploy to GitHub Pages
        id: deployment
        uses: actions/deploy-pages@v1

And that seemed to work fine. The apps were deployed to thisdot.github.io/my-repo/app1 and thisdot.github.io/my-repo/app2 respectively. But, then I noticed something was off...

Addressing Client-Side Routing

My apps were both written with React and used react-router-dom. And as GitHub Pages doesn't support client-side routing out of the box, the routing wasn't working properly and I've been getting 404 errors.

One of the apps had a workaround using a custom 404.html from spa-github-pages. The script in that file redirects all 404s to the index.html, preserving the path and query string. But that workaround wasn't working anymore at this point, and adding it to the second app didn't work either.

The reason why it wasn't working was that the 404.html wasn't in the root directory of the GitHub pages for that repository, as the apps were now deployed to subdirectories. So, the 404.html was not being picked up by the server. I needed to move the 404.html to the root directory of the apps.

I moved the 404.html to a shared folder next to the apps and updated the build script to copy it to the dist/apps directory alongside the two app subdirectories:

      - name: Move 404.html
        run: |
          mv dist/apps/app1/404.html dist/apps/404.html

So the whole workflow definition now looked like this:

name: Build and deploy static content to Pages

on:
  # Runs on pushes targeting the default branch
  push:
    branches: ['main']

  # Allows you to run this workflow manually from the Actions tab
  workflow_dispatch:

# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
permissions:
  contents: read
  pages: write
  id-token: write

# Allow one concurrent deployment
concurrency:
  group: 'pages'
  cancel-in-progress: true

jobs:
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v3
      - name: Install dependencies and run unit tests
        run: |
          npm ci
          nx run app1:test
          nx run app2:test

  deploy:
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    runs-on: ubuntu-latest
    needs: [unit-tests]
    steps:
      - name: Checkout
        uses: actions/checkout@v3
      - name: Install dependencies
        run: npm ci
      - name: Build app1
        run: |
          nx run app1:build
      - name: Build app2
        run: |
          nx run app2:build
      - name: Upload artifact
        uses: actions/upload-pages-artifact@v1
        with:
          path: 'dist/apps'
      - name: Setup Pages
        uses: actions/configure-pages@v2
      - name: Deploy to GitHub Pages
        id: deployment
        uses: actions/deploy-pages@v1

Another thing to do was to increase the segmentsToKeep variable in the 404.html script to accommodate the app subdirectories:

var pathSegmentsToKeep = 2;

Handling Truly Missing URLs

At this point, the routing was working fine for the apps and I thought I was done with this ordeal. But then someone mistyped the URL and the page just kept redirecting to itself and I was getting an infinite loop of redirects. It just kept adding ?/&/~and~/~and~/~and~/~and~/~and~/~and~ over and over again to the URL. I had to fix this.

So I dug into the 404.html page and figured out, that I'll just check the path segment corresponding to the app name and only execute the redirect logic for known app subdirectories. So I added a allowedPathSegments array and check if the path segment matches one of the allowed ones:

// allowed subdirectories
var allowedPathSegments = ['app1', 'app2'];

// if the current URL is for an allowed subdirectory, redirect to it
var shouldRedirect =
    pathSegments.length > pathSegmentsToKeep &&
    allowedPathSegments.indexOf(pathSegments[pathSegmentsToKeep]) !== -1;
if (shouldRedirect) {
    // existing redirect code
}

At that point, the infinite redirect loop was gone. But the 404 page was still not very helpful. It was just blank.

So I also took this opportunity to enhance the 404.html to list the available apps and provide some helpful information to the user in case of a truly missing page.

I just had to add a bit of HTML code into the body:

  <div id="not-found-content" style="display: none">
   <h1>404</h1>
   <p>This page not found. Looks like such an app doesn't exist.</p>
   <p>Here is a list of apps we have:</p>
   <ul id="app-list"></ul>
  </div>

And a bit of javascript to populate the list of apps and show the content:

  if (shouldRedirect) {
    // existing redirect code
   } else {
    // populate the app list and show the not-found content
    document.addEventListener('DOMContentLoaded', function () {
     var appList = document.getElementById('app-list');
     allowedPathSegments.forEach(function (segment) {
      var listItem = document.createElement('li');
      var link = document.createElement('a');
      link.href = `/my-repo/${segment}`;
      link.textContent = segment.charAt(0).toUpperCase() + segment.slice(1);
      listItem.appendChild(link);
      appList.appendChild(listItem);
     });
     document.getElementById('not-found-content').style.display = 'block';
    });
   }

Now, when a user mistypes the URL, they get a helpful message and a list of available apps to choose from. If they use one of the available apps, the routing works as expected. This is the final version of the 404.html page:

<!DOCTYPE html>
<html>
 <head>
  <meta charset="utf-8" />
  <title>Single Page Apps for GitHub Pages</title>
  <script type="text/javascript">
   // Single Page Apps for GitHub Pages
   // MIT License
   // https://github.com/rafgraph/spa-github-pages
   // This script takes the current url and converts the path and query
   // string into just a query string, and then redirects the browser
   // to the new url with only a query string and hash fragment,
   // e.g. https://www.foo.tld/one/two?a=b&c=d#qwe, becomes
   // https://www.foo.tld/?/one/two&a=b~and~c=d#qwe
   // Note: this 404.html file must be at least 512 bytes for it to work
   // with Internet Explorer (it is currently > 512 bytes)

   // If you're creating a Project Pages site and NOT using a custom domain,
   // then set pathSegmentsToKeep to 1 (enterprise users may need to set it to > 1).
   // This way the code will only replace the route part of the path, and not
   // the real directory in which the app resides, for example:
   // https://username.github.io/repo-name/one/two?a=b&c=d#qwe becomes
   // https://username.github.io/repo-name/?/one/two&a=b~and~c=d#qwe
   // Otherwise, leave pathSegmentsToKeep as 0.
   var pathSegmentsToKeep = 2;

   var allowedPathSegments = ['app1', 'app2'];

   var l = window.location;

   var pathSegments = l.pathname.split('/');

   var shouldRedirect =
    pathSegments.length > pathSegmentsToKeep &&
    allowedPathSegments.indexOf(pathSegments[pathSegmentsToKeep]) !== -1;

   if (shouldRedirect) {
    l.replace(
     l.protocol +
      '//' +
      l.hostname +
      (l.port ? ':' + l.port : '') +
      l.pathname
       .split('/')
       .slice(0, 1 + pathSegmentsToKeep)
       .join('/') +
      '/?/' +
      l.pathname
       .slice(1)
       .split('/')
       .slice(pathSegmentsToKeep)
       .join('/')
       .replace(/&/g, '~and~') +
      (l.search ? '&' + l.search.slice(1).replace(/&/g, '~and~') : '') +
      l.hash,
    );
   } else {
    document.addEventListener('DOMContentLoaded', function () {
     var appList = document.getElementById('app-list');
     allowedPathSegments.forEach(function (segment) {
      var listItem = document.createElement('li');
      var link = document.createElement('a');
      link.href = `/my-repo/${segment}`;
      link.textContent = segment.charAt(0).toUpperCase() + segment.slice(1);
      listItem.appendChild(link);
      appList.appendChild(listItem);
     });
     document.getElementById('not-found-content').style.display = 'block';
    });
   }
  </script>
 </head>
 <body>
  <div id="not-found-content" style="display: none">
   <h1>404</h1>
   <p>This page was not found. Looks like such an app doesn't exist.</p>
   <p>Here is a list of apps we have:</p>
   <ul id="app-list"></ul>
  </div>
 </body>
</html>

Conclusion

Deploying multiple apps from an Nx monorepo to GitHub Pages required some adjustments, both in the GitHub Actions workflow and in handling client-side routing. With these changes, I was able to deploy and manage two apps effectively and I should be able to deploy even more apps in the future if they get added to the monorepo.

And, while the changes were not very drastic, it wasn't easy to find information on the topic and figure out what to do. That's why I decided to write this post. and I hope it will help someone else with a similar problem.