Build for Web, Mobile & Desktop from a Single SvelteKit App

July 17, 2024

I aimed to create a simple app using SvelteKit that could be deployed on the web and packaged for mobile and desktop. The most common solution is to use @sveltejs/adapter-static to serve the app as an SPA. This approach works well with a separate backend, but I wanted to keep my backend in SvelteKit using API routes and retain the option for server-side rendering (SSR).

Screenshot showing the demo app on the web, running as a desktop app and as a mobile app
Demo of the app running on the web, desktop, and mobile, with a node backend.

We’ll create two builds of the app: one using @sveltejs/adapter-node and one using @sveltejs/adapter-static. The node build will serve the web app with SSR and host the API used by the ` build.

We’ll serve a web app, a desktop app using Tauri, and a mobile app using Capacitor. You can adapt this to other solutions if you prefer.

First, let’s create a new SvelteKit project and add the necessary adapters:

npm create svelte@latest my-sveltekit-app
cd my-sveltekit-app
npm install @sveltejs/adapter-node @sveltejs/adapter-static

Next, create a .env file with an APP_BASE variable to point the static builds towards the hosted API. For local development, this would be localhost:3000:

APP_BASE=http://localhost:3000

Now, let’s configure the builds in svelte.config.js. We’ll output to build-static and build-node based on the VITE_ADAPTER environment variable.

import adapterStatic from '@sveltejs/adapter-static';
import adapterNode from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';

// set default adapter
process.env.VITE_ADAPTER ??= 'node';

const adapterStaticConfigured = adapterStatic({
    fallback: 'index.html',
    pages: 'build-static',
    assets: 'build-static'
});

const adapterNodeConfigured = adapterNode({
    out: 'build-node'
});

// when using the node adapter, set PUBLIC_API_BASE to '' since we're not sending requests to the API
if (process.env.VITE_ADAPTER === 'node') {
    process.env.PUBLIC_API_BASE = '';
}

/** @type {import('@sveltejs/kit').Config} */
const config = {
    // Consult https://kit.svelte.dev/docs/integrations#preprocessors
    // for more information about preprocessors
    preprocess: vitePreprocess(),
    kit: {
        adapter: process.env.VITE_ADAPTER === 'node' ? adapterNodeConfigured : adapterStaticConfigured,
        outDir:
            process.env.VITE_ADAPTER === 'node' ? '.svelte-kit/build-node' : '.svelte-kit/build-static'
    } // this is important so building one adapter doesnt overwrite the other
};

export default config;

Let’s add matching build commands to package.json:

{
    // ...
    "scripts": {
        "dev": "npm run dev:node",
        "dev:static": "VITE_ADAPTER=static vite dev",
        "dev:node": "VITE_ADAPTER=node vite dev --port 3000",
        "build": "npm run build:node && npm run build:static",
        "build:node": "VITE_ADAPTER=node vite build",
        "build:static": "VITE_ADAPTER=static vite build",
        "preview": "npm run preview:node",
        "preview:static": "npm run build:static && VITE_ADAPTER=static vite preview",
        "preview:node": "npm run build:node && VITE_ADAPTER=node vite preview --port 3000"
        // ...
    }
    // ...
}

Update your TypeScript config to point to the correct build directory to avoid editor errors.

{
 "extends": "./.svelte-kit/build-node/tsconfig.json",
    // ...
}

You can now add a +layout.ts file to the root of your app. Enable prerendering on the static build and SSR on the node build. Use PUBLIC_API_BASE to determine which build we’re using.

import { PUBLIC_API_BASE } from '$env/static/public';

export const prerender = !!PUBLIC_API_BASE;
export const ssr = !PUBLIC_API_BASE;

Let’s set up a simple demo app with API routes. The API is under /api/ routes. We’re adding the /api/test which will return a random number and the request origin. We’ll call this API from our load function and display the result in a page layout.

The API returns a random number and the origin of the request.

export const GET = async ({ request }) => {
    return new Response(
        JSON.stringify({
            val: Math.random().toString(),
            origin: request.headers.get('Origin')
        }),
        {
            headers: {
                'Content-Type': 'application/json'
            }
        }
    );
};

Call the API in the load function using the PUBLIC_API_BASE prefix. For the node build, this will fetch the API from the root directly. For the static build, it will use the prefix to fetch the remote API.

import { PUBLIC_API_BASE } from '$env/static/public';

export const load = async ({ fetch, depends }) => {
    depends('app:random');

    const value = await fetch(PUBLIC_API_BASE + '/api/test').then(async (res) => {
        return await res.json();
    });

    return {
        value
    };
};

I created a small front-end to display the API call result. You can view the full code here.

We can now run both adapters using npm run dev:node and npm run dev:static and have the static build use the API from the node adapter!

Screenshot of the demo app showing two pages, one with `adapter:node` and the other with `adapter:static` showing a similar result
On the left is the node adapter and on the right is the static adapter, notice the difference in origins.

The API requests from the node adapter have null origin since SvelteKit optimizes the API call to be made without a network request.

We’ve built a simple SvelteKit app with API routes and a layout calling the API from the load function. The build is split into two: one build hosts the API and performs SSR, suitable for server hosting, while the other build is static, suitable for mobile or desktop apps.

This works well using the dev server but in production, we’ll encounter CORS errors because the API is on a different origin. We can solve this by adding a server-side hook.

Here we define an ALLOWED_ORIGINS array to specify allowed origins for API requests, since we have different origins for desktop, mobile, etc. it’s good to keep it configurable. We also include code to handle preflight requests.

import { dev } from '$app/environment';
import { PUBLIC_API_BASE } from '$env/static/public';
import type { Handle } from '@sveltejs/kit';

const ALLOWED_ORIGINS = [
    ...(dev
        ? [
                'http://localhost:4173', // vite preview
                'http://localhost:5173', // vite dev
                'http://localhost:3000' // node adapter
            ]
        : []),
    'tauri://localhost', // tauri
    'capacitor://localhost', // capacitor on iOS
    'http://localhost', // capacitor on android,
    PUBLIC_API_BASE
];

function isAllowedOrigin(origin: string | null): origin is string {
    if (!origin) return false;
    return ALLOWED_ORIGINS.includes(origin);
}

export const handle: Handle = async ({ event, resolve }) => {
    // Apply CORS header for API routes
    if (event.url.pathname.startsWith('/api')) {
        // disable API if a remote API is specified with PUBLIC_API_BASE
        if (PUBLIC_API_BASE) {
            return new Response('API is disabled', { status: 403 });
        }

        const origin = event.request.headers.get('Origin');

        console.log('API request from ' + origin);
        if (isAllowedOrigin(origin)) {
            // preflight request
            if (event.request.method === 'OPTIONS') {
                return new Response(null, {
                    headers: {
                        'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
                        'Access-Control-Allow-Origin': origin,
                        'Access-Control-Allow-Headers': '*'
                    }
                });
            }
            // actual request
            const response = await resolve(event);
            response.headers.append('Access-Control-Allow-Origin', origin);
            return response;
        }
    }

    return resolve(event);
};

With this in place, we can serve our app in production without issue.

We can now use the static build output in build-static/ with any packaging solution. Here are examples for Capacitor and Tauri.

Let’s add Capacitor to our project and initialize it:

npm install -D @capacitor/cli @capacitor/core @capacitor/ios @capacitor/android
npx cap init

Add the Android and iOS platforms to your project and sync:

npx cap add android
npx cap add ios
npx cap sync

To always have the latest static build, add a script to package.json that runs before the copy command, as well as the capacitor command:

{
    // ...
    "scripts": {
        // ...
        "capacitor:copy:before": "npm run build:static",
        "capacitor" : "capacitor"
    }
}

Make sure to point to the static build in capacitor.config.json:

import type { CapacitorConfig } from '@capacitor/cli';

const config: CapacitorConfig = {
 appId: 'com.example.sveltekituniversal',
 appName: 'sveltekituniversal',
 webDir: 'build-static'
};

export default config;

Now, try out your app with npm run capacitor run android or npm run capacitor run ios.

Install Tauri and initialize the project:

npm install -D @tauri-apps/cli
npx tauri init

When prompted, enter the correct dev & build commands. The tauri.config.json should look something like this:

{
  "$schema": "../node_modules/@tauri-apps/cli/schema.json",
  "build": {
    "beforeBuildCommand": "npm run build:static",
    "beforeDevCommand": "npm run dev:static",
    "devPath": "http://localhost:5173",
    "distDir": "../build-static"
  },
  // ...
}

You can also add the tauri command to the scripts in package.json, after which you can try out your desktop app with npm run tauri dev or npm run tauri build.

We now have a final demo app that runs on every platform!

For a ready-to-use version, check out the GitHub template repo here.