Guide: How To Deploy Preact from a Subdirectory

Table of Contents

Intro

I’ve been working on a Preact project recently, which is a single-page-application, and I decided that I wanted to host and deploy the landing page from the same project repository, on the same domain. This means that instead of serving my generated Preact files from server root (by deploying /build), I want to serve them from a subdirectory:

___ Old New
Build output: /build /static-root/app
Homepage (example.com) gives you: The app Custom static LP (/static-root/index.html)
App URL Path: / (homepage) /app

My first thought was simply to change the destination of the build output to the nested folder and change my deploy settings to host the parent folder, but, without changing anything else, this basically breaks the entire app:

  • Breaks assets, script loading, etc.
  • Breaks Preact router
  • Breaks webpack bundle output, ESM chunk loading, etc.

When I searched for the right way on how to deploy a Preact app from a subdirectory, a lot of the results I got were not very straightforward, and not necessarily even the right things to do.

I’m making this guide as a reference point for myself, and anyone else that wants to see the basic steps that are necessary to get this working.

It can be done! 😄

How to Deploy Preact from a Subdirectory

Most of these steps are not in any particular order, and they are somewhat specific to my setup; if you are using a different bundler, router, etc. – you will need to slightly tweak things. But, still a good starting point for the right things to adjust.

However, this first step should probably be done first, since it will help later:

Prepare for Changes: Setup Shared Base Path

My first step, which I’m glad I had the foresight to think through, was setting up a shared base URL / path value, which can intelligently change based on the environment the app is launched in. For example, if I launch my app in dev mode, with hot-reloading, I don’t want the app to try and use /app/* URLs, because the dev server launches the Preact app directly – not in a subdirectory.

You can set this up however, you please, but the way I did it was first adding a value to my package.json for easy configuration changes:

{
    "publicPath": "/app/"
}

This is important, because the package.json is an easy root-level spot to share values, and publicPath matches the name used by webpack (more on this later).

Next, in a common export point (I used src/constants.ts), I export a wrapper around this value:

import pkgInfo from '../package.json';
/**
 * The base URL where this app lives. Used for routes, assets, etc.
 *  - Needed for dealing with serving out of subdirectory
 */
export const APP_BASE_URL_NO_SLASH_END: string =
    process.env.NODE_ENV !== 'production'
        ? ''
        : pkgInfo.publicPath.endsWith('/')
        ? pkgInfo.publicPath.substring(0, pkgInfo.publicPath.length - 1)
        : pkgInfo.publicPath;

export const APP_BASE = APP_BASE_URL_NO_SLASH_END;

The above code uses the package.json value, which means that right now it either returns '' (empty string) if running in dev mode, or /app if actually running in production, with the nested subdirectory.

Now, I can import APP_BASE in various parts of my app, and automatically prefix links with the correct base, by doing something like ${APP_BASE}/user/edit/.

Shared Base Path: Setting Environment Variable

One issue that I quickly ran into with the above code is that, unlike most other frameworks I’ve used, Preact CLI does not actually change the value of process.env.NODE_ENV based on the command that is run. In order to have process.env.NODE_ENV = 'production' when preact build is ran, and = 'development when dev is ran, I had to install cross-env as a dependency, and then manually add those values to my package.json scripts.

For example:

{
    "scripts": {
        "dev": "cross-env NODE_ENV=development preact watch",
        "build": "cross-env NODE_ENV=production preact build"
    }
}

Configure Bundler

This is one of the most important steps; you need to update the configuration of the JavaScript bundler (e.g. Webpack) so that all the distribution files end up in the right places, with the right files pulling them in.

I won’t provide instructions for every bundler out there, but I will show how to do it for Webpack.

If you are using standard Preact template, there is a good chance you are using Webpack, with configuration controlled via the preact.config.js file. To update this config to handle a subdirectory deploy, you want to mutate the config to add a publicPath option – this tells webpack where to place generated files. Here is how I modified my preact.config.js:

// We need the shared path value!
import pkgInfo from './package.json';

export default {
    webpack(config, env, helpers, options) {
        // Rest of my config omitted
        // ...
        config.output = config.output || {};
        if (process.env.NODE_ENV === 'production') {
            config.output.publicPath = pkgInfo.publicPath;
        }
    }
}

Read more:

Fixing Router

This step can take the most work, especially if you are using preact-router and have lots of routes and links.

I highly suggest using an IDE that supports advanced Find-and-Replace searching (such as VSCode), so you can bulk update links and paths

Patching Preact-Router

Preact-Router does not currently support a base path option, although there is an issue and open PR to add it. In the meantime, it is feasible to patch the functionality in-place.

First, I created a wrapper method around the normal route() function. This is in TypeScript, which makes it little more verbose in dealing with function overloading signatures (route() is overloaded in its exported definition):

// Notice: I need the automatic sub-dir path from where it is exported
import { APP_BASE_URL_NO_SLASH_END } from '../constants';
import { route } from 'preact-router';

/**
 * Wrapper around preact-router `route()`, since they have not implemented a
 * base URL option, needed for subdirectories, or custom base paths
 */
export const appRoute: RouteFuncSignatures = (
    input: string | { url: string; replace?: boolean },
    replace?: boolean
) => {
    const inputUrl = typeof input === 'string' ? input : input.url;
    let patchedUrl = inputUrl;
    if (!patchedUrl.startsWith(APP_BASE_URL_NO_SLASH_END)) {
        const sep = !patchedUrl.startsWith('/') ? '/' : '';
        patchedUrl = `${APP_BASE_URL_NO_SLASH_END}${sep}${patchedUrl}`;
    }

    if (typeof input === 'string') {
        return route(patchedUrl, replace);
    }

    return route({
        url: patchedUrl,
        replace: input.replace,
    });
};

interface RouteFuncSignatures {
    (url: string, replace?: boolean): boolean;
    (options: { url: string; replace?: boolean }): boolean;
}

Now, I can simply find all uses of route() and replace them with my appRoute() wrapper function (which takes identical arguments, so I can literally find and replace all instances).

I also need to fix how I have defined my route matches. Luckily, this is a lot easier; I just need to prefix all the paths with my shared prefix:

Diff:

<Router onChange={handleRoute}>
    <Route
-       path="/user/create"
+       path={`${APP_BASE}/user/create`}
        component={User}
    />
</Router>

There are other ways you could patch this – e.g., you could create a wrapper around <Router></Router> instead.

Anywhere that you have hard-coded links, that are not relative, you need to update them to either be relative (which should work with the subdirectory move), or prefix with the shared subdirectory base.

Examples:

- <a href="/dashboard"> Dashboard </a>
+ <a href={`${APP_BASE}/dashboard`}> Dashboard </a>
- <Link href="/dashboard"> Dashboard </Link>
+ <Link href={`${APP_BASE}/dashboard`}> Dashboard </Link>

PWA Support: Double-Check manifest.json

If you are deploying your app as a PWA, you will probably want to check your manifest.json file, and make sure that the URLs within it are relative and not absolute, as well as update the start_url value to point to the subdirectory and not the root path (/).

Update Deploy Config

This step is unrelated to Preact, and would be required for any project where you have changed what folders should become the root of the host / deployment server.

This step is also going to be unique to your deployment situation. For example, if you using an automated system like Netlify, it should just require changing some values through an Admin UI (Example: Netlify – change the publish directory option).

Summarizing: A Checklist

Here are all the steps I’ve listed, as a checklist that you can scan through if you are still having issues getting things working.

Alternate Approaches

Through all the steps I listed, my approach was to store the subdirectory path string in my package.json, but that is not the only option. If we wanted an even more flexible system, where the same repo could be deployed to multiple different subdirectories, we could pull the subdirectory path from an environmental variable instead of hard-coding it into the package file. Then, in each deployment, we would just need to make sure the build server had that environmental variable populated with the correct value for its target.

I’m sure there are many more approaches I haven’t discussed; as always, use what works best for you and your requirements. And happy coding! 👩‍💻

Animated GIF showing Dee Dee from Dexter's laboratory typing frantically

Leave a Reply

Your email address will not be published. Required fields are marked *