Deploying a Static SvelteKit Application on a Website's Subdirectory

2026/07/08

The goal

  1. I want to build a static SvelteKit application and plop those built files into my web server so the application can be served from the endpoint [site-url]/piano.
  2. I want to keep all these repositories separate to isolate dependencies and minimize the amount of overhead in running the web server.

The problems

  1. SvelteKit is ideal for rapidly developing full-stack web applications that require interaction with the web server, but my use case isn't that. This application runs entirely locally, so I'm looking for a tool that makes a bunch of static HTML, CSS, and JS files I can just drag and drop into the folder my web server serves up.
  2. Since the app will be nested in a subdirectory of the web server's root folder, we'll need to point all links to that directory by prepending every href with /piano, including links in the HTML head pointing to CSS and JS assets.
  3. We'll need a way to copy the built SvelteKit files into the piano/ subdirectory of the web server.

The solution

First, use @sveltejs/adapter-static to build the application in its own repository.

  1. In the SvelteKit app's folder, run npm i @sveltejs/adapter-static.
  2. Modify your vite.config.js|ts (which is where SvelteKit has moved its configuration in an admirable effort to cull some of our many root-level configuration files):
import adapter from "@sveltejs/adapter-static";
import { sveltekit } from "@sveltejs/kit/vite";
import { defineConfig } from "vite";

export default defineConfig({
    plugins: [
        sveltekit({
            compilerOptions: {
                // Force runes mode for the project, except for libraries. Can be removed in svelte 6.
                runes: ({ filename }) =>
                    filename.split(/[/\\]/).includes("node_modules")
                        ? undefined
                        : true,
            },
            adapter: adapter(),
            paths: {
                base: "/piano", // Change this field to your target subdirectory
                relative: false,
            },
        }),
    ],
});

The docs say the base property under the paths property allows us to specify a value for SvelteKit to prepend to all of its assets, and the relative flag being off ensures %sveltekit.assets% and references to build artifacts will always be root-relative paths. The docs also note the next step:

"Note that you need to prepend all your root-relative links with the base value or they will point to the root of your domain, not your base (this is how the browser works)"

Awkwardly, it then suggests using base from $app/paths, which appears to be deprecated. Fret not, we can accomplish this with a simple helper function.

  1. Make a file called make-path.ts or similar (or add it to your junk drawer of utils.ts).
import { PUBLIC_BASE_URL } from "$env/static/public";

export const makePath = (path: string): string => `${PUBLIC_BASE_URL}${path}`;
  1. This is the tedious part. Modify all your href attributes to call this function, like in this sample consumer:
<script lang="ts">
    import { makePath } from "$lib/make-path";
</script>

// BEFORE
<a class="back-home" href="/">← Back home</a>

// AFTER
<a class="back-home" href={makePath("/")}>← Back home</a>

Voila! At this point, run your development server and note how all the URLs have the proper, prepended path. When you run npm run build, your bundled app should now be ready to serve!

  1. Copy the built files to the subdirectory of the web server.

The last thing to do is to modify your build process to copy over the SvelteKit app's built files into the intended subdirectory. This step is going to differ based on how your build process is configured. In my case, this is a simple npm script that I run after building the frontend repository.

Currently, this script looks for an environment variable called SITE_SOURCE, and if present, copies the contents of that path into the dist directory of the web server, which is the folder that's served up online.

That makes the process for updating and deploying the website on my VPS look like:

# Build the frontend
cd apps/jthome
git pull
npm run build

# Collect the files
cd ../jtwebserver
git pull
npm run collect

# Restart the server with the new files included
pm2 restart jtserver

I like this pattern because no repository paths are hard-coded; wherever the SITE_SOURCE may be on the target machine doesn't matter, and if no environment variable is set, the script doesn't run and prints an error.

Anticipating future use cases in which I'd like to deploy other isolated applications under subdirectories, I created a new file called modules.js, which will be in charge of looking for external repositories' content and copying them over to the web server if available.

import fs from "fs";

const MODULES = [
    {
        destination: "./dist/piano",
        enabled: true,
        execute: false, // Import and run the module when true, otherwise just copy files
        source: "../piano-curriculum/build",
    },
];

const copyModule = ({ destination, enabled, source }) => {
    if (!enabled) {
        return;
    }
    if (!fs.existsSync(destination)) {
        fs.mkdirSync(destination);
    }
    fs.cpSync(source, destination, { recursive: true });
    console.log(`Copied ${source} to ${destination}`);
};

const copyModules = () => {
    if (MODULES.every((module) => module.enabled === false)) {
        console.log("No modules enabled");
        return;
    }
    MODULES.forEach((module) => copyModule(module));
};

export default copyModules;

Then, invoke this copyModules() method in the script the "collect" script:

...

// Pull from SITE_SOURCE
console.log("Copying...");
fs.cpSync(siteSource, distDirectory, { recursive: true });

console.log(`Copied files from ${siteSource} to ${distDirectory}!`);

// Copy modules if enabled
copyModules();

The MODULES array contains any information about any external repositories whose build artifacts I'd like to copy to the web server. The enabled property can be edited before running the script if I want to fine-tune which modules will be copied over. The execute property is currently unused, but was put in place in case I have server-side logic that needs to run to deploy an external application. In the case of this SvelteKit application, I just need to copy files over to the destination and it should "just work".

In the future, adding another external app to my web server will simply entail adding another object to MODULES.

Comments

SvelteKit isn't intended for making static, self-contained apps with just client-side logic, but it can certainly be used in this way! I appreciate Svelte's approach of opting for a compiler instead of a runtime dependency like React (without the new React Compiler) or Vue. This allows programming in a powerful superscript of JavaScript but without the performance detriments or dependency management headaches that typically accompany JavaScript frameworks. I did struggle with state management and I'm wondering if I'm misusing or overusing the $derived() rune.

Scroll to top