Deploy Ionic app to subfolder with Firebase

5 min read

The TopVault mobile app is built with Ionic. It is also accessible via a web application using Ionic's built-in web deployment features. This allows users to have the same experience on mobile and on the web.

The original deployment used a subdomain, specifically app.vault.top to host the Ionic app. This worked well because this subdomain could host specific deep and universal link metadata, as well as most other well-known files were specific to the application. In this configuration the bare domain vault.top was used to host a welcome page.

Downsides to subdomains

The main downside to hosting the application in a subdomain was how search engines and other web tools consider "the application". In my perspective both the bare domain, and app-specific subdomain were the same "application" and even the site's blog powered by hashnode, was a feature of the application; yet this was also hosted on a blog subdomain.

Each of these domains needed a distinct property in Google Search Console. They each required search indexers to index metadata such as favicons. And for the most part, the metadata hosted in well-known paths off the domain included repeated information. Indexing and authority was also unique to each.

All this considered, a simpler experience meant consolidating the content under the single bare vault.top domain, and hosting the application in a subfolder, and the blog in a subfolder. Thankfully both Ionic and Hashnode support this well with minor technology changes.

Reconfiguring Ionic

The main goal for the migration, from subdomain app. to subfolder /app, was zero impact to the mobile experience, and minor impact to the web experience limited to a client-side redirection. All existing paths would remain the same, and any stored links would be redirected correctly. Here is the final result of configurations.

In the Ionic app's folder, located at ./app in the TopVault mono-repo:

A fairly basic ./app/capacitor.config.js:

const config: CapacitorConfig = {
    appId: 'top.vault',
    appName: 'TopVault',
    webDir: 'dist',

    // Plugins/etc here.
};

The index.html file was modified to include the /app prefix:

<!doctype html>
<html lang="en" mode="ios">
    <head>
        <!-- Example: -->
        <link rel="manifest" href="/app/manifest.json" />
        <link rel="icon" type="image/x-icon" href="/app/favicon.ico" />
        <link rel="icon" sizes="192x192" href="/app/assets/icons/icon-192.webp" />
        <link rel="icon" sizes="512x512" href="/app/assets/icons/icon-512.webp" />
        <link rel="apple-touch-icon" sizes="192x192" href="/app/assets/icons/icon-192.webp" />
    </head>
    <body>
        <div id="root"></div>
        <script type="module" src="/src/main.tsx"></script>
    </body>
</html>

The Vite config at ./app/vite.config.ts:

const viteConfig = defineConfig({
    base: '/',
    build: {
        assetsDir: 'app/assets',
        outDir: 'dist',
    },
    // resolve, define, build, server, etc.
});

Within the application code, any usage of paths in router links, history contexts, and routes are updated with the /app prefix. This likely requires many files to be updated. I tried handling this more simply within the react router outlet by setting a prefix, but then internal logic around path handling became complex.

All routes now have the prefix included. And this is the same for asset usage, which had been /assets and is now /app/assets.

<IonRouterOutlet>
    <Route exact path="/">
        <Redirect to="/app/home" />
    </Route>
    <Route exact path="/app">
        <Redirect to="/app/home" />
    </Route>
    <Route path="/app/home" exact component={HomePage} />
<IonRouterOutlet>

The actual assets folder within /app/public was moved to /app/public/app/assets.

All build commands and scripts remain the same. Using the default workflow for building and then releasing through XCode or Android Studio also remain the same.

Reconfiguring Firebase

The Firebase hosting configuration had contained two targets, for example website and app. These were configured in the Firebase console for the bare domain and app subdomain respectively. To combine them the first step was to migrate all redirect and rewrites to the target for the bare domain. This is straightforward as all of the path expressions moved gain the /app prefix.

The public folder for the bare domain target remains the same.

A predeploy step is added that compiles the web version of the Ionic app and places it into a suitable deploy location for Firebase.

In this example, the bare domain's index.html and other static content is located in the mono repo at ./website/public. And the Ionic app is developed in ./app.

{
  "hosting": [{
    "target": "website",
    "public": "website/public",
    "predeploy": [
      "(cd app && npm run build:production)",
      "rm -rf website/public/app",
      "cp -r app/dist/app website/public/app",
      "cp app/dist/index.html website/public/app"
    ],
  }]
}

And the repository's .gitignore protects this deployment location from being committed:

$ cat .gitignore | grep app
website/public/app

Then using the Firebase CLI, both the website and application are synchronized and deployed:

firebase deploy --only hosting

Reconfiguring Hashnode blog

The goal was to move blog.vault.top to vault.top/blog, again from the subdomain to a subfolder.

Hashnode supports this as part of their "headless" deployment feature. This is an option in a blog's setting on Hashnode, located in the same panel as configuring a subdomain. Switching the blog to headless-mode is required for canonical and sitemap links to work correctly, and this mode will ask for the new location of the blog's frontend.

For this deployment scenario a frontend is required, and they provide an out-of-the-box example as part of their starter-kit project on GitHub. I am unsure if a pure-PWA/SPA implementation is possible. Deploying this according to the documentation in Vercel is well documented in the project's README.

Fortunately for TopVault, CloudFlare is already in the technology stack. This means deploying the example CloudFlare Worker example was also straightforward.

The CloudFlare Worker route is configured as vault.top/blog* and the preview and workers domain are disabled for simplicity. The worker implementation was directly adapted from the starter-kit project:

const subpath = '/blog';
const blogBaseUrl = 'https://<randomly-generated-project-id>.vercel.app';  

addEventListener('fetch', event => { 
   event.respondWith(handleRequest(event.request)) 
}) 

async function handleRequest(request) {   
   const url = new URL(request.url)    

   if (url.pathname.startsWith(subpath)) {
       // Proxy blog requests.
       return proxyBlog(request)   
   } else {
       // Passthrough everything else.
       return fetch(request)   
   } 
}  

async function proxyBlog(request) { 
    const path = new URL(request.url).pathname;
    // Clean up 404's being returned from unimplemented /ping endpoint.
    if (path.startsWith(`${subpath}/ping`)) {
      return new Response(null, { status: 200 });
    }
    return fetch(`${blogBaseUrl}${path}`, request) 
}