Build for Web, Mobile & Desktop from a Single SvelteKit App
July 17, 2024I 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).
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.
Project Setup
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
SvelteKit Configuration
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;
Build Commands
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"
// ...
}
// ...
}
TypeScript Configuration
Update your TypeScript config to point to the correct build directory to avoid editor errors.
{
"extends": "./.svelte-kit/build-node/tsconfig.json",
// ...
}
Prerendering & SSR Support
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;
Demo App
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.
API Function
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'
}
}
);
};
Load Function
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
};
};
Small Demo Front-End
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!
The API requests from the node adapter have null origin since SvelteKit optimizes the API call to be made without a network request.
Recap
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.
Solving CORS Issues
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.
Setting Up Other Platforms
We can now use the static build output in build-static/
with any packaging solution. Here are examples for Capacitor and Tauri.
Mobile with Capacitor
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
.
Desktop with Tauri
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
.
GitHub Template
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.