Skip to main content

Build Desktop Applications with Tauri

Updated 6 min readMarkdown
Build Desktop Applications with Tauri

"JavaScript developers can only build web apps."

I hear this a lot, and it's not true anymore. With Tauri, you can build a desktop app using the frontend skills you already have. In this post I'll walk through how I set it up, and the parts that confused me along the way.

1. Why Tauri?

Tauri has two parts. Your UI runs inside the operating system's own webview, and the backend is written in Rust. Because it doesn't bundle a whole browser the way Electron does, the installers are small. You also get Rust's speed and safety for anything heavy.

2. Setting up a new project

If you're new to Tauri, start from scratch. It's much easier to see what each file does.

npm create tauri-app@latest

The CLI asks a few questions:

  • Identifier: usually a reverse domain, like com.yourcompany.app

  • Frontend language: TypeScript/JavaScript (I'd pick this)

  • Package manager: pnpm, yarn, npm or bun

  • UI template: React, Vue, Svelte, Solid and so on

Then install and run:

cd project_name
npm install
npm run tauri dev

A native window opens with your app inside.

2.1 Adding Tauri to an existing React project

npm install --save-dev @tauri-apps/cli
npm install @tauri-apps/api
npx tauri init

tauri init creates a src-tauri folder with the Rust side. Add "tauri": "tauri" to the scripts in package.json, then npm run tauri dev will work.

3. How the frontend talks to Rust

Think of it as two programs. React renders the UI, and Rust has the native powers (filesystem, OS APIs, heavy logic). They talk through commands: you write a Rust function, then call it from JavaScript with invoke.

Rust side:

#[tauri::command]
fn greet(name: &str) -> String {
    format!("Hello, {}!", name)
}

pub fn run() {
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![greet])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

React side:

import { useState } from "react";
import { invoke } from "@tauri-apps/api/core";

function App() {
  const [name, setName] = useState("");
  const [greeting, setGreeting] = useState("");

  async function greetUser() {
    setGreeting(await invoke("greet", { name }));
  }

  return (
    <div>
      <input value={name} onChange={(e) => setName(e.target.value)} />
      <button onClick={greetUser}>Greet</button>
      <p>{greeting}</p>
    </div>
  );
}

export default App;

The string "greet" must match the Rust function name, and the argument keys must match the Rust parameter names.

3.1 The setup hook

.setup(|app| { ... }) runs once, after Tauri initializes and before the app starts. It's the right place to touch windows, register state, start background tasks or load plugins.

use tauri::{Emitter, Manager};

.setup(|app| {
    let window = app.get_webview_window("main").unwrap();
    window.set_title("My App").unwrap();

    app.emit("backend-ready", "Rust side is up")?;
    Ok(())
})

The |app| { } part is just a Rust closure, similar to an arrow function in JS. Don't forget to return Ok(()) at the end.

To receive the event in React:

import { listen } from "@tauri-apps/api/event";

listen("backend-ready", (event) => {
  console.log(event.payload);
});

One catch: an event emitted inside setup can fire before your React code has registered a listener, so you might miss it. For anything critical, let the frontend ask for the data with invoke.

3.2 Running things in the background

Events can go the other way too. Here Rust fires an event every 30 seconds, and React reacts to it. I use this pattern to trigger data sync.

// awake_interval.rs
use std::time::Duration;
use tauri::{AppHandle, Emitter};

pub async fn background_timer(app_handle: AppHandle) {
    let mut interval = tokio::time::interval(Duration::from_secs(30));
    loop {
        interval.tick().await;
        if let Err(e) = app_handle.emit("background_sync_trigger", ()) {
            eprintln!("Failed to emit event: {}", e);
        }
    }
}
// lib.rs
mod awake_interval;

pub fn run() {
    tauri::Builder::default()
        .setup(|app| {
            let handle = app.handle().clone();
            tauri::async_runtime::spawn(awake_interval::background_timer(handle));
            Ok(())
        })
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}
// useBackgroundSync.js
import { useEffect, useState } from "react";
import { listen } from "@tauri-apps/api/event";

export default function useBackgroundSync(syncFn) {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const unlisten = listen("background_sync_trigger", async () => {
      setCount((c) => c + 1);
      try {
        await syncFn?.();
      } catch (error) {
        console.error("Sync error:", error);
      }
    });

    return () => {
      unlisten.then((fn) => fn());
    };
  }, [syncFn]);

  return count;
}

Wrap syncFn in useCallback where you use the hook, otherwise the listener gets re-registered on every render.

3.3 Controlling the window

You can also control how the window behaves. For example, instead of quitting when the user clicks the close button, you can hide the window so the app keeps running in the background:

.setup(|app| {
    if let Some(window) = app.get_webview_window("main") {
        let w = window.clone();
        window.on_window_event(move |event| {
            if let tauri::WindowEvent::CloseRequested { api, .. } = event {
                api.prevent_close();
                let _ = w.hide();
            }
        });
    }
    Ok(())
})

Be careful with this. If you hide the window, the user needs a way to bring it back, usually a system tray icon. Blocking the close button with no way back is a bad experience.

For launching on startup, use the official autostart plugin:

npm run tauri add autostart

After that, you can enable it from Rust using the plugin's manager.

4. Making network calls

The webview's normal fetch still works, but it follows browser rules, so the server has to allow CORS. In a desktop app that gets annoying, because you often don't control the server.

The Tauri HTTP plugin solves this by making the request from the Rust side. It also lets you restrict which URLs the app can reach.

npm run tauri add http

This installs both the Rust crate and the JS package. Then allow your URLs in src-tauri/capabilities/default.json:

{
  "identifier": "default",
  "windows": ["main"],
  "permissions": [
    "core:default",
    {
      "identifier": "http:default",
      "allow": [
        { "url": "http://localhost:9000/*" },
        { "url": "https://your-api.com/*" }
      ]
    }
  ]
}

Any URL not on this list is blocked. Now import fetch from the plugin:

import { fetch } from "@tauri-apps/plugin-http";

export const userLogin = async ({ email, password }) => {
  const BASE_URL = import.meta.env.VITE_BASE_URL;

  const response = await fetch(`${BASE_URL}/auth/login`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ email, password }),
  });

  return response.json();
};

5. Wrapping up

You don't need to learn Rust to start with Tauri. Commands, events and the SETUP hook are enough to get a real app running, and you can pick up Rust as you go. 🚀

No comments yet. Be the first to comment.