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.