Getting started with SvelteKit + Tauri v2: static builds, windows, and file permissions
We'll walk you through the static build, window structure, IPC, and file permission design required to move a SvelteKit app to a Tauri v2 desktop app.
When moving a web project to a desktop app, the first thing that changes is the deployment method.
Putting a SvelteKit interface into Tauri can look deceptively simple: build the frontend, then let Tauri display the result. Launching the first window is indeed quick. After that, though, the work is no longer a straight port of the same SvelteKit app; it is a redefinition of boundaries for a desktop runtime without a server.
The first premise is simple: Tauri does not run SvelteKit's Node server alongside the app. It serves built HTML, CSS, and JavaScript from within the application, then connects only the required operating-system features through Rust and plugins. Expecting server-process features such as +page.server.ts, form actions, or server hooks inside a desktop bundle will quickly tangle the architecture.
This article covers the minimum structure needed to get started with desktop apps with Tauri v2 for those who have already used SvelteKit. Let's connect static builds, window settings, frontend and Rust boundaries, and file reading and writing permissions in one flow. The question of whether to choose between Tauri and Electron was first summarized in SvelteKit desktop app comparison article. Here we focus on creating a foolproof starting point once you decide to go with Tauri.
Rather than abandoning server functions, separate server roles
SvelteKit is a framework that naturally embraces SSR and server routes. So, in web services, it is convenient to have the page's load function fetch data from the server, login processing to form action, and secret key writing to +server.ts. However, Tauri's default distribution does not include the Node runtime to continue running these servers.
This difference does not mean giving up functionality. It's close to meaning to change roles. Open/public data is sent to the existing HTTPS API, file and window control on the user's device is sent to the Tauri plugin or Rust commands, and operations that require secret values are still sent to an external backend. The moment you insert an API key into a desktop app and take on the role of a server, the structure that seemed convenient is immediately exposed in the distribution.
So it's a good idea to sort out these three things before you start:
- UI, status, and API calls that can be executed in the browser
- File, window, notification, and tray operations that must be performed only within the user's device
- Authentication, payment, secret key, and authority verification tasks that must remain on the server
If you don't do this classification, you'll end up spending time fixing window missing errors and API authentication failures. If categorized in the opposite way, Tauri is not a replacement for a web app, but rather a shell that attaches thin, clear local functionality to the web front end.
Static build is not an option, it is the default format for the desktop
The key when using SvelteKit in Tauri is @sveltejs/adapter-static. Create static output that your app can read, and point Tauri's frontendDist to that directory. If you have a SPA with dynamic URLs in mind, you can set fallback: 'index.html' to force the client router to handle refresh and direct entry as well.
npm install -D @sveltejs/adapter-static @tauri-apps/cli
npm install @tauri-apps/api
npm run tauri init
If you already have a SvelteKit project, the above commands are just a starting point. Be sure to match your project's package manager and command structure, and treat the generated src-tauri like a separate native project. Frontend dependencies and Rust dependencies make up the same app, but their update cycles and failure mechanisms are different.
Let svelte.config.js produce static output like this:
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({
fallback: 'index.html'
})
}
};
export default config;
Note that fallback here is not a magic setting that solves all your problems. This is a device that returns index.html so that the client can process unknown paths at build time, such as /projects/123 after login. If the screen depends on server-only load or form action, it will not work even if there is a fallback. That data needs to be turned into an API that can be called from the browser.
Turn off SSR on the root layout.
// src/routes/+layout.ts
export const ssr = false;
export const prerender = false;
ssr = false makes it easier to handle code that requires a window, like the Tauri API, without conditionals. prerender = false is useful when specifying fallback-based SPA operation. However, for apps where all paths are confirmed at build time, such as static documents and public landing screens, the prerender = true strategy is also possible. Before mixing the two, it is much safer to first document “who gets the data from this screen, when, and where?”
Configuring Tauri involves agreeing on a single build path.
During development, the Vite dev server provides the UI, and during release builds, the static directory provides the UI. If these two paths do not match, the classic problem occurs where the app opens fine in development mode, but a blank window appears in the packaged app.
// src-tauri/tauri.conf.json
{
"$schema": "../gen/schemas/desktop-schema.json",
"productName": "Desk Notes",
"version": "0.1.0",
"identifier": "com.example.desknote",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:5173",
"beforeBuildCommand": "npm run build",
"frontendDist": "../build"
},
"app": {
"windows": [
{
"label": "main",
"title": "Desk Notes",
"width": 1200,
"height": 800,
"minWidth": 900,
"minHeight": 640,
"resizable": true
}
]
},
"bundle": {
"active": true,
"targets": "all"
}
}
The default output path of the SvelteKit static adapter and frontendDist above may vary from project to project. What's important is not the convention, but the actual directory created after npm run build. Once built, check to see if build/index.html exists, and if not, fix either the adapter output path or the Tauri configuration. If you guess both places at the same time, you lose the cause.
Development and package verification must be separated.
# 개발 서버 + Tauri 창
npm run tauri dev
# 정적 SvelteKit 빌드 + 네이티브 번들 생성
npm run tauri build
In development mode, devUrl's server does everything for you, so it's easy to miss issues like file paths, CSPs, and app resource paths. Whenever a feature is added, you need to get into the habit of running it on the packaged results at least once. In particular, relative path images, dynamic routes, and external OAuth redirects tend to break first in a release.
A window can be a permission boundary rather than a screen
At first, it's easy to think of windows as just a design issue. Just add the width, height, title, minimum size, and so on. However, in Tauri v2, window labels can be the unit that binds permissions. It is important that the title shown to the user and the label used in the security settings are different values.
For example, let's say your editor only saves local files in the main window, and the help or login windows don't need that functionality. If you attach the same capability to all windows, the frontend running in the help window also has permission to write files. Although it looks simple, it increases the attack surface unnecessarily.
For a new window, it is better to first decide “what it should be able to do” rather than “what it should show,” and then decide the label and capabilities. In particular, for windows that display external URLs or for plugin authentication, it is better to have narrower permissions than the default window.
Even when you need to create a window in code, the label is treated as a fixed identifier.
use tauri::{Manager, WebviewUrl, WebviewWindowBuilder};
fn open_preferences(app: &tauri::AppHandle) -> tauri::Result<()> {
if app.get_webview_window("preferences").is_none() {
WebviewWindowBuilder::new(
app,
"preferences",
WebviewUrl::App("/preferences".into())
)
.title("환경설정")
.inner_size(720.0, 560.0)
.resizable(false)
.build()?;
}
Ok(())
}
This is the same reason that a window is not created by putting user input directly into the label. Permission settings are associated with labels rather than titles, so creating window names on the fly makes capability design difficult to track. You should also consider separately whether the window creation itself needs to be open in all windows.
Frontend and Rust are connected by a thin contract
When you first encounter Rust in Tauri, it's easy to feel like you need to move all your logic to Rust. Most apps don't. Instant feedback from UI state, form validation, screen transitions, and network response representation can be left to SvelteKit. On the Rust side, we leave only tasks that are close to the operating system and tasks that need to be verified within the trust boundary.
For example, a command that tells the front desk the app data folder is short and clear.
// src-tauri/src/lib.rs
#[tauri::command]
fn app_data_dir(app: tauri::AppHandle) -> Result<String, String> {
app.path()
.app_data_dir()
.map(|path| path.to_string_lossy().to_string())
.map_err(|error| error.to_string())
}
pub fn run() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![app_data_dir])
.run(tauri::generate_context!())
.expect("error while running Tauri application");
}
The frontend should call the command only when it runs inside the app. If the same component is also used in a browser preview, wrap Tauri behind a thin adapter.
// src/lib/platform/desktop.ts
import { browser } from '$app/environment';
export async function getAppDataDir(): Promise<string | null> {
if (!browser || !('__TAURI_INTERNALS__' in window)) return null;
const { invoke } = await import('@tauri-apps/api/core');
return invoke<string>('app_data_dir');
}
<script lang="ts">
import { onMount } from 'svelte';
import { getAppDataDir } from '$lib/platform/desktop';
let directory: string | null = null;
onMount(async () => {
directory = await getAppDataDir();
});
</script>
{#if directory}
<p>로컬 데이터는 <code>{directory}</code>에 저장됩니다.</p>
{:else}
<p>브라우저 미리보기에서는 로컬 앱 경로를 표시하지 않습니다.</p>
{/if}
The important thing here is not to call invoke directly throughout the service. Passing a module like desktop.ts allows you to have a single point of departure for web previews, testing, and even future Capacitor extensions. If you are also thinking about mobile WebView, Web view boundaries encountered in SvelteKit + Capacitor and Capacitor and Tauri's selection criteria are also worth reading from the same perspective.
Selecting a file and accessing a file are two different things
If you add an “Open File” button to the Tauri app but encounter a permission error, the problem usually starts because you think that file selection and file access are the same function. The dialog plugin lets the user select a file. The fs plugin allows you to read and write that path. These are two different APIs, and fs operations require two layers of restrictions: command permissions and path scope.
First, add the required plugins.
npm run tauri add dialog
npm run tauri add fs
If the main window only reads and writes app data documents, it is better to write the capability together with specific commands and paths.
// src-tauri/capabilities/main-editor.json
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "main-editor",
"description": "Capability for the local note editor",
"windows": ["main"],
"permissions": [
"dialog:allow-open",
"fs:allow-read-text-file",
{
"identifier": "fs:allow-write-text-file",
"allow": [{ "path": "$APPDATA/notes/*.md" }]
},
{
"identifier": "fs:allow-read-text-file",
"allow": [{ "path": "$APPDATA/notes/*.md" }]
}
]
}
The above settings are only examples, and the actual permission identifier must be checked in the schema and document created by the installed plugin version. The reason I emphasize this point is because it is very tempting to eliminate the problem by putting fs:default first. If you give a function that saves a single file permission to read and write the entire home, the operation will be faster, but the design will be worse.
Separating selection and saving at the front also makes the user experience clearer.
import { open } from '@tauri-apps/plugin-dialog';
import { readTextFile, writeTextFile } from '@tauri-apps/plugin-fs';
export async function importMarkdown(): Promise<string | null> {
const path = await open({
multiple: false,
directory: false,
filters: [{ name: 'Markdown', extensions: ['md', 'mdx'] }]
});
if (typeof path !== 'string') return null;
return readTextFile(path);
}
export async function saveDraft(path: string, content: string) {
await writeTextFile(path, content);
}
If your app needs to continuously edit random files selected by the user, you should design a separate lifecycle for the “first selected external file”. A practical way to reduce complexity is to copy it to the app data folder when importing, and then edit it only within $APPDATA/notes. This method narrows the scope and gathers backup, synchronization, and deletion policies in one place. On the other hand, if the product requires the user to edit the original file directly, re-access of the selected path and platform-specific file permissions must be treated as test cases.
Permission errors are a signal to narrow down your questions before expanding your settings.
When you see not allowed or promise rejection during development, it's easy to choose to open full permissions. But this error usually begs a good question. Does this window really need to use this API? Is it just reading, or is writing also necessary? Is the entire directory necessary, or can just one folder under the app data be sufficient? Would it be better to verify the file path in a Rust command instead of getting it directly from the UI?
Tauri v2's capabilities·permission·scope are covered in more depth in a later article. Tauri v2 least-privilege design: capability·permission·scope explains what units to divide the file system, external URLs, and multiple windows into, followed by an audit checklist. It is enough to remember to first accept permission errors as a design review point and not as a functional failure.
Verification sequence that does not end in development mode
The fact that the app is turned on is just the beginning of functional verification. The point where the web frontend and native shell meet can behave differently in development mode and package mode. Checking in the following order will help you narrow down the cause.
npm run buildcreates static output and checks whetherindex.htmlexists in the configuredfrontendDist.- Run
npm run tauri devto see if the Window, Routing, and Browser Preview branches work as expected. - Run the app created with
npm run tauri buildto check direct URL entry, refresh, and dynamic routes. - Execute file selection, reading, and saving once each to determine at which stage the permission error occurs.
- If there is a separate window such as Help, Login, or Settings, check that the file function of the main window is not called from that window.
- Verify package execution at least once for each actual supported platform: macOS, Windows, or Linux. Because Tauri uses OS WebView, CSS and input behavior should not be generalized based on results from just one platform.
Especially right before deployment, it is best not to consider ‘runs on the development Mac’ and ‘installed on another user’s operating system’ as the same passing criteria. Code signing, operating system security warnings, WebView readiness, and file access policies are entirely different types of operational issues.
A good starting point is not an app with few features, but an app with few boundaries
When you create your first app with SvelteKit + Tauri, you'll want to quickly expand your feature list. However, the best option early on is to make your app's boundaries clear. The front runs as a static bundle, anything the server needs to do stays on the server, local functions only go through explicit Tauri APIs, and file permissions only have the paths they need.
Once you have this structure in place, it will be easier to determine where to place additional features such as tray, auto-update, window state restoration, local database, and mobile extensions in the future. Conversely, if you increase functionality while this boundary is blurred, even a small desktop app will soon become an app with web, Rust, and operating system permissions intertwined.
Whether your reason for choosing Tauri is light distribution or narrower native rights, the starting point is the same. Your code and configuration should be able to describe in the same language what your app installs on the user's device, what it asks you to do, and how much access it has.