---
title: "Desktop apps with Tauri 2 and Vue · vuejs.school"
description: "Turn a Vue app into a desktop app with Tauri 2. Scaffold it, call Rust with invoke, open a native file dialog, grant permissions and build an installer."
canonical_url: "https://vuejs.school/community/blog/desktop-apps-with-tauri-2-and-vue"
meta:
  "og:description": "Turn a Vue app into a desktop app with Tauri 2. Scaffold it, call Rust with invoke, open a native file dialog, grant permissions and build an installer."
  "og:image": "https://vuejs.school/_og/d/a_~RGVza3RvcCBhcHBzIHdpdGggVGF1cmkgMiBhbmQgVnVlIMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_Desktop%2Bapps%2Bwith%2BTauri%2B2%2Band%2BVue%2Cdescription_~VHVybiBhIFZ1ZSBhcHAgaW50byBhIGRlc2t0b3AgYXBwIHdpdGggVGF1cmkgMi4gU2NhZmZvbGQgaXQsIGNhbGwgUnVzdCB3aXRoIGludm9rZSwgb3BlbiBhIG5hdGl2ZSBmaWxlIGRpYWxvZywgZ3JhbnQgcGVybWlzc2lvbnMgYW5kIGJ1aWxkIGFuIGluc3RhbGxlci4%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy9kZXNrdG9wLWFwcHMtd2l0aC10YXVyaS0yLWFuZC12dWUi%2Cs_1cexEQ-UUjPEi3mB.png"
  "og:image:alt": "Desktop apps with Tauri 2 and Vue · vuejs.school"
  "og:image:height": "600"
  "og:image:type": image/png
  "og:image:width": "1200"
  "og:title": "Desktop apps with Tauri 2 and Vue · vuejs.school"
  "twitter:image": "https://vuejs.school/_og/d/a_~RGVza3RvcCBhcHBzIHdpdGggVGF1cmkgMiBhbmQgVnVlIMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_Desktop%2Bapps%2Bwith%2BTauri%2B2%2Band%2BVue%2Cdescription_~VHVybiBhIFZ1ZSBhcHAgaW50byBhIGRlc2t0b3AgYXBwIHdpdGggVGF1cmkgMi4gU2NhZmZvbGQgaXQsIGNhbGwgUnVzdCB3aXRoIGludm9rZSwgb3BlbiBhIG5hdGl2ZSBmaWxlIGRpYWxvZywgZ3JhbnQgcGVybWlzc2lvbnMgYW5kIGJ1aWxkIGFuIGluc3RhbGxlci4%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy9kZXNrdG9wLWFwcHMtd2l0aC10YXVyaS0yLWFuZC12dWUi%2Cs_1cexEQ-UUjPEi3mB.png"
  "twitter:image:alt": "Desktop apps with Tauri 2 and Vue · vuejs.school"
---

> Index of every page on this site: [llms.txt](https://vuejs.school/llms.txt)

## Install Rust and the system dependencies

Tauri compiles a Rust program, so you need the Rust toolchain. On macOS and Linux, install it with rustup, the official installer:

```
curl --proto '=https' --tlsv1.2 https://sh.rustup.rs -sSf | sh
```

On Windows, run the rustup installer from rust-lang.org and keep the MSVC toolchain as the default. The `tauri` crate declares Rust 1.77.2 as its minimum, and rustup gives you the current stable release, which is newer.

Each operating system also needs a few native pieces, listed in the [prerequisites guide](https://tauri.app/start/prerequisites/):

- macOS: Xcode, or just its command line tools ( `xcode-select --install`) if you only build desktop apps.
- Windows: the Microsoft C++ Build Tools with "Desktop development with C++" ticked, plus WebView2, which Windows 10 version 1803 and later already include.
- Debian or Ubuntu: `libwebkit2gtk-4.1-dev`, `build-essential` and a handful of libraries the guide names.

You also need Node.js, the LTS release.

## Create the project

Run the official scaffolding tool and answer its prompts:

```
npm create tauri-app@latest
# Project name: word-counter
# Identifier: com.example.wordcounter
# Frontend language: TypeScript / JavaScript
# Package manager: npm
# UI template: Vue, flavour: TypeScript
cd word-counter
npm install
npm run tauri dev
```

The project has two halves:

- `src/` is a normal Vite and Vue app, with `main.ts` and `App.vue`.
- `src-tauri/` is a Rust project. `src/lib.rs` holds your Rust code, `tauri.conf.json` holds the app's configuration, and `capabilities/` decides what the webview may call. `src/main.rs` is the desktop entry point. It only calls `run()` from `lib.rs`, so leave it alone.

`npm run tauri dev` starts Vite on port 1420, opens a native window that loads it, and watches both halves: Vue changes hot reload as usual, and a change under `src-tauri` rebuilds the Rust side and restarts the app. The first run downloads and compiles every Rust dependency, which takes several minutes. Later runs only rebuild your own code.

The template pins Vite 6 and `@vitejs/plugin-vue` 5, which accepts only Vite 5 and 6. To move to Vite 8, update both with `npm install -D vite@8 @vitejs/plugin-vue@6`. Vite 8 needs Node 20.19 or a later 20.x release, or Node 22.12 or later.

## Call Rust from Vue

A command is a Rust function the frontend can call. Replace the template's `greet` command with one that reads a file and counts its words:

*src-tauri/src/lib.rs*

```
#[tauri::command]
fn count_words(path: String) -> Result<usize, String> {
    let text = std::fs::read_to_string(&path).map_err(|err| err.to_string())?;
    Ok(text.split_whitespace().count())
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_opener::init())
        .invoke_handler(tauri::generate_handler![count_words])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}
```

The `#[tauri::command]` attribute makes the function callable, and `generate_handler!` registers it. Whatever a command returns is serialised to JSON, and an `Err` rejects the promise on the JavaScript side.

Commands written in `lib.rs` must not be marked `pub`. The generated glue code would then define the same name twice, and the build stops with `error[E0255]`. Commands in a separate module are the opposite case: they need `pub`.

On the Vue side, `invoke` from `@tauri-apps/api/core` calls the command by name. Wrap it in a typed function, so components never repeat the command's name or guess what it returns:

*src/lib/words.ts*

```
import { invoke } from '@tauri-apps/api/core'

export function countWords(path: string): Promise<number> {
  return invoke<number>('count_words', { path })
}
```

Arguments travel as one object whose keys match the Rust parameter names, written in camelCase: a Rust `file_path` becomes `filePath` in JavaScript.

## Open a native file dialog with a plugin

Tauri keeps its core small and puts features such as dialogs, the file system and notifications into official plugins. Add the dialog plugin with the CLI:

```
npm run tauri add dialog
```

That one command adds the `tauri-plugin-dialog` crate, installs `@tauri-apps/plugin-dialog`, inserts `.plugin(tauri_plugin_dialog::init())` into `lib.rs` and grants the plugin's default permission. Now the component can ask the user for a file and pass the path to Rust:

*src/App.vue*

```
<script setup lang="ts">
import { ref } from 'vue'
import { open } from '@tauri-apps/plugin-dialog'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { countWords } from './lib/words'

const fileName = ref('')
const words = ref<number | null>(null)
const error = ref('')

async function pickFile() {
  error.value = ''
  const path = await open({
    multiple: false,
    directory: false,
    filters: [{ name: 'Text', extensions: ['txt', 'md'] }],
  })
  if (!path) return // the user closed the dialog
  try {
    words.value = await countWords(path)
    fileName.value = path.split(/[\\/]/).pop() ?? path
    await getCurrentWindow().setTitle(`Word counter: ${fileName.value}`)
  } catch (err) {
    error.value = String(err)
  }
}
</script>

<template>
  <main>
    <button type="button" @click="pickFile">Choose a text file</button>
    <p v-if="words !== null">{{ fileName }} has {{ words }} words.</p>
    <p v-if="error" role="alert">{{ error }}</p>
  </main>
</template>
```

`open` shows the operating system's own file picker. With `multiple` and `directory` both `false`, it resolves with one path as a string, or `null` when the user cancels. On desktop that is a normal file system path, which `std::fs` in the command reads directly. The `split` keeps only the file name for display, whether the system separates folders with `/` or `\`.

Run the app and pick a file: the count appears. The window title does not change, though, and the error paragraph says why.

## Grant permissions with capabilities

Code in the webview reaches the system only through Tauri's IPC layer, the bridge that carries `invoke` calls. Plugin and core commands on that bridge run only when a capability grants them. Capabilities live as JSON files in `src-tauri/capabilities`; here is the template's file after the changes this app needs:

*src-tauri/capabilities/default.json*

```
{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "default",
  "description": "Capability for the main window",
  "windows": ["main"],
  "permissions": [
    "core:default",
    "opener:default",
    "dialog:default",
    "core:window:allow-set-title"
  ]
}
```

A capability names the windows it applies to, here only `main`, and the permissions it grants:

- `core:default` covers the basics. Its window permissions mostly read state, such as the size or the title.
- `opener:default` came with the template's opener plugin. It lets the app open web, mail and phone links in their default app and show a file in the file manager.
- `dialog:default` was added by `tauri add` and allows the ask, confirm, message, open and save dialogs.
- `core:window:allow-set-title` is the line this app adds. Changing the title is not part of `core:default`, so `setTitle` was rejected, and in a dev build the error names this permission. Add the line and the title updates.

Commands you register yourself with `invoke_handler` are allowed in every window by default. That makes `count_words` your responsibility: it reads any path the frontend sends, so in a real app check the path before you trust it.

## Set the window size and title

The starting window is configured in `tauri.conf.json`, under `app.windows`. The template opens an 800 by 600 window titled with the project name. A word counter needs less room:

*src-tauri/tauri.conf.json*

```
{
  "app": {
    "windows": [
      {
        "title": "Word counter",
        "width": 480,
        "height": 360,
        "minWidth": 360,
        "minHeight": 240
      }
    ]
  }
}
```

That shows only the `app.windows` part; keep the rest of the generated file. A window without a `label` is called `main`, which is the name the capability above refers to. If you add a second window, give it a label and decide which capability, if any, should include it.

## Build an installer

One command builds the Vue app, compiles Rust in release mode and packages the result:

```
npm run tauri build
```

The installers land in `src-tauri/target/release/bundle/`, one folder per format, such as a `.dmg` on macOS or an `.msi` and a setup `.exe` on Windows.

Tauri's docs say a minimal app can be less than 600 KB, because it uses the webview already on the user's machine, while every Electron app ships its own copy of Chromium and Node.js. Your Vue bundle and Rust code add to that figure, but the browser engine never does.

Build each installer on the system it is for, on your own machine or in a CI job such as the official tauri-action for GitHub Actions; the docs treat cross-compiling Windows installers from Linux or macOS as a last resort. Most platforms also expect the app to be code signed before users can open it without a warning.

**Check it stuck**3 questions from the Desktop apps with Tauri quiz.

[**Practice now**](https://vuejs.school/learn/multiplatform/desktop-apps-with-tauri?tab=quiz)

**Was this helpful?**

## **Keep reading**

[Tutorial · 7 min read**Tauri plugins, mobile builds and the updater**](https://vuejs.school/community/blog/tauri-plugins-mobile-builds-and-the-updater) [Tutorial · 7 min read**Mobile UI with Ionic Vue 8**](https://vuejs.school/community/blog/mobile-ui-with-ionic-vue-8)

```json
{"@context":"https://schema.org","@graph":[{"@id":"https://vuejs.school/#website","@type":"WebSite","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school/","publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/desktop-apps-with-tauri-2-and-vue#webpage","@type":"WebPage","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2026-04-07","description":"Turn a Vue app into a desktop app with Tauri 2. Scaffold it, call Rust with invoke, open a native file dialog, grant permissions and build an installer.","name":"Desktop apps with Tauri 2 and Vue","url":"https://vuejs.school/community/blog/desktop-apps-with-tauri-2-and-vue","breadcrumb":{"@id":"https://vuejs.school/community/blog/desktop-apps-with-tauri-2-and-vue#breadcrumb"},"isPartOf":{"@id":"https://vuejs.school/#website"},"potentialAction":[{"@type":"ReadAction","target":["https://vuejs.school/community/blog/desktop-apps-with-tauri-2-and-vue"]}],"primaryImageOfPage":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/#identity","@type":"Organization","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school","logo":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/community/blog/desktop-apps-with-tauri-2-and-vue#article","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2026-04-07","description":"Turn a Vue app into a desktop app with Tauri 2. Scaffold it, call Rust with invoke, open a native file dialog, grant permissions and build an installer.","headline":"Desktop apps with Tauri 2 and Vue","inLanguage":"en","isAccessibleForFree":true,"thumbnailUrl":"https://vuejs.school/_og/d/a_~RGVza3RvcCBhcHBzIHdpdGggVGF1cmkgMiBhbmQgVnVlIMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_Desktop%2Bapps%2Bwith%2BTauri%2B2%2Band%2BVue%2Cdescription_~VHVybiBhIFZ1ZSBhcHAgaW50byBhIGRlc2t0b3AgYXBwIHdpdGggVGF1cmkgMi4gU2NhZmZvbGQgaXQsIGNhbGwgUnVzdCB3aXRoIGludm9rZSwgb3BlbiBhIG5hdGl2ZSBmaWxlIGRpYWxvZywgZ3JhbnQgcGVybWlzc2lvbnMgYW5kIGJ1aWxkIGFuIGluc3RhbGxlci4%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy9kZXNrdG9wLWFwcHMtd2l0aC10YXVyaS0yLWFuZC12dWUi%2Cs_1cexEQ-UUjPEi3mB.png","@type":["Article","BlogPosting"],"articleSection":["Tutorial"],"author":{"@id":"https://vuejs.school/#identity"},"image":{"@id":"https://vuejs.school/#/schema/image/1"},"isPartOf":{"@id":"https://vuejs.school/community/blog/desktop-apps-with-tauri-2-and-vue#webpage"},"mainEntityOfPage":{"@id":"https://vuejs.school/community/blog/desktop-apps-with-tauri-2-and-vue#webpage"},"publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/desktop-apps-with-tauri-2-and-vue#breadcrumb","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","name":"Blog","item":"https://vuejs.school/community/blog","position":1},{"@type":"ListItem","name":"Desktop apps with Tauri 2 and Vue","position":2}]},{"@id":"https://vuejs.school/#logo","@type":"ImageObject","caption":"vuejs.school","contentUrl":"https://vuejs.school/apple-touch-icon.png","inLanguage":"en","url":"https://vuejs.school/apple-touch-icon.png"},{"@id":"https://vuejs.school/#/schema/image/1","@type":"ImageObject","contentUrl":"https://vuejs.school/_og/d/a_~RGVza3RvcCBhcHBzIHdpdGggVGF1cmkgMiBhbmQgVnVlIMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_Desktop%2Bapps%2Bwith%2BTauri%2B2%2Band%2BVue%2Cdescription_~VHVybiBhIFZ1ZSBhcHAgaW50byBhIGRlc2t0b3AgYXBwIHdpdGggVGF1cmkgMi4gU2NhZmZvbGQgaXQsIGNhbGwgUnVzdCB3aXRoIGludm9rZSwgb3BlbiBhIG5hdGl2ZSBmaWxlIGRpYWxvZywgZ3JhbnQgcGVybWlzc2lvbnMgYW5kIGJ1aWxkIGFuIGluc3RhbGxlci4%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy9kZXNrdG9wLWFwcHMtd2l0aC10YXVyaS0yLWFuZC12dWUi%2Cs_1cexEQ-UUjPEi3mB.png","inLanguage":"en","url":"https://vuejs.school/_og/d/a_~RGVza3RvcCBhcHBzIHdpdGggVGF1cmkgMiBhbmQgVnVlIMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_Desktop%2Bapps%2Bwith%2BTauri%2B2%2Band%2BVue%2Cdescription_~VHVybiBhIFZ1ZSBhcHAgaW50byBhIGRlc2t0b3AgYXBwIHdpdGggVGF1cmkgMi4gU2NhZmZvbGQgaXQsIGNhbGwgUnVzdCB3aXRoIGludm9rZSwgb3BlbiBhIG5hdGl2ZSBmaWxlIGRpYWxvZywgZ3JhbnQgcGVybWlzc2lvbnMgYW5kIGJ1aWxkIGFuIGluc3RhbGxlci4%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy9kZXNrdG9wLWFwcHMtd2l0aC10YXVyaS0yLWFuZC12dWUi%2Cs_1cexEQ-UUjPEi3mB.png"}]}
```