diff --git a/.github/workflows/deploy-gh.yml b/.github/workflows/deploy-gh.yml
index c2c5148..25c35b1 100644
--- a/.github/workflows/deploy-gh.yml
+++ b/.github/workflows/deploy-gh.yml
@@ -26,27 +26,30 @@ jobs:
         uses: actions/checkout@v4
 
       - name: Use Node.js
-        uses: actions/setup-node@v3
+        uses: actions/setup-node@v4
         with:
           node-version-file: '.nvmrc'
 
       - name: Cache node_modules
-        uses: actions/cache@v3
+        uses: actions/cache@v4
         id: cache-node-modules
         with:
           path: node_modules
-          key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package.json') }}
+          key: ${{ runner.os }}-build-${{ hashFiles('**/package.json') }}
 
       - name: Cache dist
-        uses: actions/cache@v3
+        uses: actions/cache@v4
         id: cache-dist
         with:
           path: packages/client/dist
-          key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ github.sha }}
+          key: ${{ runner.os }}-build-${{ github.sha }}
 
       - name: Install
         run: npm install
 
+      - name: Setup SPA on Github Pages
+        run: node packages/client/tasks/setup-gh-pages.mjs
+
       - name: Build
         run: npm run all:build
 
@@ -59,14 +62,11 @@ jobs:
         uses: actions/checkout@v4
 
       - name: Restore dist cache
-        uses: actions/cache@v3
+        uses: actions/cache@v4
         id: cache-dist
         with:
           path: packages/client/dist
-          key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ github.sha }}
-
-      - name: Copy index as 400 file for github pages
-        run: cp packages/client/dist/index.html packages/client/dist/400.html
+          key: ${{ runner.os }}-build-${{ github.sha }}
 
       - name: Deploy 🚀
         uses: JamesIves/github-pages-deploy-action@v4
diff --git a/packages/client/.env b/packages/client/.env
index 3dc31a9..d221d15 100644
--- a/packages/client/.env
+++ b/packages/client/.env
@@ -8,4 +8,7 @@ REACT_APP_STAC_API=
 
 ## Theming
 # REACT_APP_THEME_PRIMARY_COLOR='#6A5ACD'
-# REACT_APP_THEME_SECONDARY_COLOR='#048A81'
\ No newline at end of file
+# REACT_APP_THEME_SECONDARY_COLOR='#048A81'
+
+## Don't set the public url here. Check the README.md file for more information
+# PUBLIC_URL= Do not set here
\ No newline at end of file
diff --git a/packages/client/README.md b/packages/client/README.md
index 320b73b..5b28d1d 100644
--- a/packages/client/README.md
+++ b/packages/client/README.md
@@ -14,6 +14,7 @@ Some client options are controlled by environment variables. These are:
 ## Title and description of the app for metadata
 APP_TITLE
 APP_DESCRIPTION
+PUBLIC_URL
 
 # API
 ## If the app is being served in from a subfolder, the domain url must be set.
@@ -26,6 +27,15 @@ REACT_APP_THEME_PRIMARY_COLOR
 REACT_APP_THEME_SECONDARY_COLOR
 ```
 
+**Public URL**  
+It is recommended to always set the `PUBLIC_URL` environment variable on a production build.
+If the app is being served from a subfolder, the `PUBLIC_URL` should include the subfolder path. **Do not include a trailing slash.**
+
+For example, if the app is being served from `https://example.com/stac-manager`, the `PUBLIC_URL` should be set to `https://example.com/stac-manager`.
+
+> [!IMPORTANT]
+> The `PUBLIC_URL` environment variable must be set before running the build script, and therefore the `.env` file cannot be used to set this variable.
+
 You must provide a value for the `REACT_APP_STAC_API` environment variable. This should be the URL of the STAC API you wish to interact with.
 
 If the `REACT_APP_STAC_BROWSER` environment variable is not set, [Radiant Earth's STAC Browser](https://radiantearth.github.io/stac-browser/) will be used by default, which will connect to the STAC API specified in `REACT_APP_STAC_API`.
diff --git a/packages/client/posthtml.config.js b/packages/client/posthtml.config.js
index e4ff1d4..c8c18dc 100644
--- a/packages/client/posthtml.config.js
+++ b/packages/client/posthtml.config.js
@@ -28,7 +28,7 @@ module.exports = {
       locals: {
         appTitle: process.env.APP_TITLE,
         appDescription: process.env.APP_DESCRIPTION,
-        baseurl: process.env.PUBLIC_URL || ''
+        baseurl: process.env.PUBLIC_URL || '/'
       }
     }
   }
diff --git a/packages/client/src/App.tsx b/packages/client/src/App.tsx
index 48b6dbd..0096e6f 100644
--- a/packages/client/src/App.tsx
+++ b/packages/client/src/App.tsx
@@ -25,11 +25,21 @@ import CollectionDetail from './pages/CollectionDetail';
 import Sandbox from './pages/Sandbox';
 import { config } from './plugin-system/config';
 
+let basename: string | undefined;
+if (process.env.PUBLIC_URL) {
+  try {
+    basename = new URL(process.env.PUBLIC_URL).pathname;
+    // eslint-disable-next-line @typescript-eslint/no-unused-vars
+  } catch (error) {
+    // no-op
+  }
+}
+
 export const App = () => (
   <ChakraProvider theme={theme}>
     <StacApiProvider apiUrl={process.env.REACT_APP_STAC_API!}>
       <PluginConfigProvider config={config}>
-        <Router>
+        <Router basename={basename}>
           <Container
             maxW='container.xl'
             minH='100vh'
@@ -46,7 +56,7 @@ export const App = () => (
             >
               <Flex gap={4} alignItems='center'>
                 <Image
-                  src='/meta/icon-512.png'
+                  src={`${process.env.PUBLIC_URL}/meta/icon-512.png`}
                   width={8}
                   aspectRatio={1}
                   borderRadius='md'
diff --git a/packages/client/src/pages/Home.tsx b/packages/client/src/pages/Home.tsx
index ea1e092..61188c3 100644
--- a/packages/client/src/pages/Home.tsx
+++ b/packages/client/src/pages/Home.tsx
@@ -1,10 +1,10 @@
 import React from 'react';
+import { Navigate } from 'react-router-dom';
 
 import { usePageTitle } from '../hooks';
-import { Navigate } from 'react-router-dom';
 
 function Home() {
-  usePageTitle('STAC Admin');
+  usePageTitle(process.env.APP_TITLE!);
 
   return <Navigate to='/collections' replace />;
 }
diff --git a/packages/client/tasks/build.mjs b/packages/client/tasks/build.mjs
index 76fcf1f..fcea7ad 100644
--- a/packages/client/tasks/build.mjs
+++ b/packages/client/tasks/build.mjs
@@ -34,7 +34,15 @@ async function copyFiles() {
   log.info('📦 Copied static files to dist.');
 }
 
-async function parcelServe() {
+async function parcelBuild() {
+  const publicUrl = process.env.PUBLIC_URL || '/';
+
+  if (publicUrl && publicUrl !== '/') {
+    log.warn(`🌍 Building using public URL: ${publicUrl}`);
+  } else {
+    log.warn(`🌍 Building without public URL`);
+  }
+
   const bundler = new Parcel({
     entries: `${__dirname}/../src/index.html`,
     defaultConfig: `${__dirname}/../.parcelrc`,
@@ -42,7 +50,7 @@ async function parcelServe() {
     mode: 'production',
     defaultTargetOptions: {
       distDir: `${__dirname}/../dist`,
-      publicUrl: process.env.PUBLIC_URL || '/'
+      publicUrl
     },
     additionalReporters: [
       {
@@ -58,8 +66,9 @@ async function parcelServe() {
     log.info(`✨ Built ${bundles.length} bundles in ${buildTime}ms!`);
   } catch (err) {
     log.warn(err.diagnostics);
+    process.exit(1);
   }
 }
 
 copyFiles();
-parcelServe();
+parcelBuild();
diff --git a/packages/client/tasks/setup-gh-pages.mjs b/packages/client/tasks/setup-gh-pages.mjs
new file mode 100644
index 0000000..cf4cfa3
--- /dev/null
+++ b/packages/client/tasks/setup-gh-pages.mjs
@@ -0,0 +1,121 @@
+/* global process */
+import path from 'path';
+import { fileURLToPath } from 'url';
+import fs from 'fs-extra';
+import log from 'fancy-log';
+
+// Adapted into a script from: https://github.com/rafgraph/spa-github-pages/tree/gh-pages
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+const baseUrl = process.env.PUBLIC_URL || '';
+
+const pathIndex = path.join(__dirname, '../src/index.html');
+const path404 = path.join(__dirname, '../static/404.html');
+
+async function main() {
+  log.info('📦 Setting up single page apps on GitHub Pages.');
+
+  const has404 = await fs.pathExists(path404);
+
+  if (has404) {
+    log.warn('📦 Found custom 404.html. Skipping setup.');
+    process.exit(0);
+  }
+
+  if (!baseUrl) {
+    log.warn(
+      '📦 Public URL not set. Assuming the app is deployed to the root.'
+    );
+  }
+
+  let segments = 0;
+  if (baseUrl) {
+    try {
+      segments = new URL(baseUrl).pathname.split('/').length - 1;
+      // eslint-disable-next-line @typescript-eslint/no-unused-vars
+    } catch (error) {
+      // no-op
+    }
+    log.info(`📦 Using ${baseUrl} with ${segments} path segments.`);
+  }
+
+  const templateScript = `<!-- Start Single Page Apps for GitHub Pages -->
+    <script type="text/javascript">
+      // Single Page Apps for GitHub Pages
+      // MIT License
+      // https://github.com/rafgraph/spa-github-pages
+      // This script checks to see if a redirect is present in the query string,
+      // converts it back into the correct url and adds it to the
+      // browser's history using window.history.replaceState(...),
+      // which won't cause the browser to attempt to load the new url.
+      // When the single page app is loaded further down in this file,
+      // the correct url will be waiting in the browser's history for
+      // the single page app to route accordingly.
+      (function(l) {
+        if (l.search[1] === '/' ) {
+          var decoded = l.search.slice(1).split('&').map(function(s) { 
+            return s.replace(/~and~/g, '&')
+          }).join('?');
+          window.history.replaceState(null, null,
+              l.pathname.slice(0, -1) + decoded + l.hash
+          );
+        }
+      }(window.location))
+    </script>
+    <!-- End Single Page Apps for GitHub Pages -->`;
+
+  // Write to index head.
+  const index = await fs.readFile(pathIndex, 'utf8');
+  const newIndex = index.replace('<head>', `<head>\n${templateScript}`);
+  await fs.writeFile(pathIndex, newIndex);
+
+  const template404 = `<!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 = ${segments};
+
+      var l = window.location;
+      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
+      );
+
+    </script>
+  </head>
+  <body>
+  </body>
+</html>`;
+
+  // Write to 404.html.
+  await fs.writeFile(path404, template404);
+
+  log.info('✅ GitHub Pages setup complete.');
+}
+
+main();