Build a Markdown Notes App with Tauri
Build a native desktop notes app with a Rust backend and live markdown preview.
Introduction
30 Second Summary
Note-taking apps are everywhere, but most rely on cloud syncing and heavy frameworks that eat up memory. What if you could build your own lightweight desktop app that stores notes locally and launches in under a second?
In this project, you will build a markdown notes app using Tauri, a framework that pairs a web frontend with a Rust backend to create fast, small native applications. You will write Rust commands, call them from TypeScript, and package the result into a Windows installer.
What You'll Build
A native Windows desktop application with a split-pane markdown editor on the left and a live HTML preview on the right, powered by Rust file operations and a vanilla TypeScript frontend.
By the end of this project, you'll have:
- A Tauri v2 desktop app with a vanilla TypeScript frontend and a Rust backend.
- Custom Rust commands that save, load, list, and delete markdown notes from the local file system.
- A live markdown editor with real-time HTML preview that auto-saves your work.
- A compiled Windows .exe installer ready to distribute to others.
- Secret Mission: Add a system tray icon that lets you quickly create a new note or show/hide the app window.
Are there any prerequisites?
You should be comfortable with HTML, CSS, and JavaScript basics. You do not need any prior Rust experience. This project teaches you the Rust you need as you go.
Set Up Your Tauri Development Environment
Before you can build a desktop app with Tauri, your machine needs a few system-level tools in place.
Tauri compiles native code using Rust and renders your UI through Microsoft Edge WebView2. In this step, you will install the required Windows dependencies, scaffold your Tauri project, and confirm everything works by launching the default app window.
In this step, get ready to:
- Install and verify the Windows system dependencies Tauri needs.
- Scaffold a new Tauri project with a vanilla TypeScript frontend.
- Launch the dev server and confirm the app window opens.
Install Windows system dependencies
Tauri requires Microsoft C++ Build Tools for compiling Rust code on Windows, and Microsoft Edge WebView2 for rendering your app's UI.
Verify Microsoft C++ Build Tools
Tauri's Rust compiler needs the MSVC toolchain to link native code on Windows. The Microsoft C++ Build Tools provide this toolchain.
- Check whether you already have the Build Tools installed by looking for Visual Studio Build Tools or Visual Studio in your Start menu.
- If you find it, open the Visual Studio Installer and confirm the Desktop development with C++ workload is checked.
✔️ I have it installed with the C++ workload
You are all set. Move on to the WebView2 check below.
ⓧ I don't have it installed
- Open the Microsoft C++ Build Tools download page.
- Click the download button to get the Build Tools installer.
- Run the installer. When the workload selection screen appears, check Desktop development with C++.
- Click Install and wait for the installation to complete.
- Restart your computer if prompted.
Why do I need this?
Rust on Windows uses the MSVC linker and Windows SDK headers provided by these Build Tools. Without them, Rust cannot compile native binaries.
Verify WebView2
Tauri uses Microsoft Edge WebView2 to render your web-based frontend inside a native window. It is pre-installed on Windows 10 (version 1803 and later) and Windows 11.
✔️ I'm on Windows 10 1803+ or Windows 11
WebView2 is already installed on your system. Move on to the Rust update step below.
ⓧ I'm on an older Windows version or it's missing
- Open the WebView2 Runtime download page.
- Under Evergreen Bootstrapper, click Download.
- Run the downloaded installer. It downloads and installs the WebView2 Runtime automatically.
Update Rust
Since you already have rustup installed, make sure your Rust toolchain is up to date. Tauri v2 requires a recent stable Rust release.
- Update Rust to the latest stable version by running this command:
rustup update
You should see output confirming your stable toolchain has been updated or is already up to date.
Seeing 'rustup is not recognized'?
This means rustup is not in your system PATH. Close and reopen your terminal, then try again. If it still fails, reinstall Rust from https://www.rust-lang.org/tools/install.
help me fix a 'rustup is not recognized' error on Windows
Scaffold the Tauri project
With your system dependencies in place, it is time to create the project. Tauri provides a scaffold command that sets up both the frontend and the Rust backend in one go.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your Tauri project by running this command:
npm create tauri-app@latest
The scaffold wizard asks you a series of prompts. Answer each one as shown below.
- Enter markdown-notes for the project name.
- Enter com.markdown-notes.app for the identifier.
- Select TypeScript / JavaScript for the language.
- Select npm as the package manager.
- Select Vanilla for the UI template.
- Select TypeScript for the UI flavor.
What does this scaffold create?
The command generates a markdown-notes folder containing two main parts. The src/ folder holds your TypeScript frontend code (HTML, CSS, and TS). The src-tauri/ folder holds your Rust backend code, Cargo manifest, and Tauri configuration.
- Navigate into your new project folder by running:
cd markdown-notes
- Install the npm dependencies by running:
npm install
You should see output indicating packages were installed successfully.
npm install failing?
Make sure you are inside the markdown-notes folder (not one level above it). Run ls and confirm you see package.json in the output.
help me fix npm install errors in my Tauri project
Verify the setup works
The quickest way to confirm everything is wired up correctly is to launch the Tauri development server. This compiles the Rust backend and opens your app in a native window.
- Start the Tauri dev server by running:
npm run tauri dev
What happens when you run this?
Tauri compiles your Rust backend (this takes a minute or two on the first run as all dependencies are downloaded and compiled). Once compilation finishes, a native window opens displaying the default Tauri template page.
If Rust compilation succeeds, you know your C++ Build Tools and Rust toolchain are working correctly. If the window renders, WebView2 is working too.
You should see a native window appear on your screen showing the default Tauri welcome page with the Tauri logo and some links.
- Close the window to stop the dev server.
Window not appearing?
Check the terminal output for error messages. Common issues include:
- "linker 'link.exe' not found" means the C++ Build Tools are missing or the Desktop development with C++ workload was not selected. Reinstall via the Visual Studio Installer.
- "error: could not compile" on the first run can mean your Rust version is too old. Run rustup update again.
- If the window opens but shows a blank white screen, WebView2 may need to be updated or reinstalled.
help me debug why my Tauri dev window is not appearing
Your development environment is ready. Next up, you will build the notes UI with a split-pane editor and live markdown preview.
Build the Notes UI
Your development environment is ready and the default Tauri template runs successfully. Now it's time to build the interface your users will interact with.
In this step, you'll create a split-pane layout with a notes sidebar on the left, a markdown editor in the center, and a live preview panel on the right. By the end, typing markdown in the editor will instantly render as formatted HTML in the preview pane.
In this step, get ready to:
- Build a split-pane HTML layout with a sidebar, editor, and preview pane.
- Style the application with a dark sidebar and light editor area.
- Add live markdown rendering using the marked library.
Create the split-pane HTML layout
The app needs three distinct areas: a sidebar to list saved notes, a text area to write Markdown, and a preview pane to display the rendered output.
- In VS Code, open src/index.html from the markdown-notes project folder.
- Select all the existing content and replace it with this code:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Markdown Notes</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<!-- Main app container with flexbox layout -->
<div class="app">
<!-- Left sidebar for the notes list -->
<aside class="sidebar">
<div class="sidebar-header">
<h2>Notes</h2>
<button id="new-note-btn">+ New</button>
</div>
<ul id="notes-list"></ul>
</aside>
<!-- Right area split into editor and preview -->
<main class="editor-area">
<div class="editor-pane">
<textarea id="editor" placeholder="Write your markdown here..."></textarea>
</div>
<div class="preview-pane">
<div id="preview"></div>
</div>
</main>
</div>
<script type="module" src="/main.ts"></script>
</body>
</html>
What does this code do?
The layout uses a flexbox container (.app) to place the sidebar and editor area side by side.
- The aside.sidebar holds a header with the app title and a "+ New" button, plus an empty <ul> that will be populated with note names later.
- The main.editor-area is split into two halves: a <textarea> for writing and a <div> for the rendered preview.
- The <script type="module"> tag loads your TypeScript entry point, which Vite compiles on the fly.
- Save src/index.html.
You won't see a visual change yet because the stylesheet is still the scaffold default. The next substep adds styles, then you'll verify both together.
✔️ Awesome, I've got everything!
Great. Make sure you've saved the file before moving on.
ⓧ I'd like to double check the full code
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Markdown Notes</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<div class="app">
<aside class="sidebar">
<div class="sidebar-header">
<h2>Notes</h2>
<button id="new-note-btn">+ New</button>
</div>
<ul id="notes-list"></ul>
</aside>
<main class="editor-area">
<div class="editor-pane">
<textarea id="editor" placeholder="Write your markdown here..."></textarea>
</div>
<div class="preview-pane">
<div id="preview"></div>
</div>
</main>
</div>
<script type="module" src="/main.ts"></script>
</body>
</html>
Style the application
The HTML structure is in place, but without styles everything renders as plain unstyled elements. You'll add a stylesheet that creates the dark sidebar on the left and a light editor/preview area on the right.
- Open src/styles.css in VS Code.
- Select all the existing content and replace it with this code:
/* Reset browser defaults */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
height: 100vh;
overflow: hidden;
}
/* Main flex container fills the viewport */
.app {
display: flex;
height: 100vh;
}
/* Dark sidebar on the left */
.sidebar {
width: 220px;
background: #1e1e2e;
color: #cdd6f4;
display: flex;
flex-direction: column;
border-right: 1px solid #313244;
}
.sidebar-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid #313244;
}
.sidebar-header h2 {
font-size: 16px;
font-weight: 600;
}
/* New note button styling */
#new-note-btn {
background: #89b4fa;
color: #1e1e2e;
border: none;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
}
#new-note-btn:hover {
background: #74c7ec;
}
/* Notes list in the sidebar */
#notes-list {
list-style: none;
overflow-y: auto;
flex: 1;
}
#notes-list li {
padding: 12px 16px;
cursor: pointer;
border-bottom: 1px solid #313244;
font-size: 14px;
}
#notes-list li:hover {
background: #313244;
}
#notes-list li.active {
background: #45475a;
}
/* Editor area takes remaining space */
.editor-area {
flex: 1;
display: flex;
}
.editor-pane {
flex: 1;
display: flex;
border-right: 1px solid #e0e0e0;
}
/* Monospace font for the markdown editor */
#editor {
width: 100%;
height: 100%;
padding: 20px;
border: none;
resize: none;
font-family: "Cascadia Code", "Fira Code", "Consolas", monospace;
font-size: 14px;
line-height: 1.6;
outline: none;
background: #fafafa;
}
/* Preview pane for rendered markdown */
.preview-pane {
flex: 1;
overflow-y: auto;
padding: 20px;
background: #ffffff;
}
#preview {
font-size: 14px;
line-height: 1.8;
color: #333;
}
#preview h1, #preview h2, #preview h3 {
margin-top: 16px;
margin-bottom: 8px;
}
#preview p {
margin-bottom: 12px;
}
#preview code {
background: #f0f0f0;
padding: 2px 6px;
border-radius: 3px;
font-family: "Cascadia Code", "Fira Code", "Consolas", monospace;
font-size: 13px;
}
#preview pre {
background: #f0f0f0;
padding: 16px;
border-radius: 6px;
overflow-x: auto;
margin-bottom: 12px;
}
#preview pre code {
background: none;
padding: 0;
}
What does this stylesheet do?
The stylesheet creates a three-column visual layout using flexbox.
- The sidebar gets a dark background (#1e1e2e) with light text, making it feel like a panel in a code editor.
- The editor uses a monospace font stack (Cascadia Code, Fira Code, Consolas) so markdown source looks clean and aligned.
- The preview pane uses a sans-serif font with generous line-height for comfortable reading of rendered HTML.
- Save src/styles.css.
- Start the dev server by running this command in your terminal:
npm run tauri dev
- Wait for the Rust compilation to finish and the app window to open.
You should see a dark sidebar on the left with a "Notes" heading and a blue "+ New" button. The right side is split into two light-coloured panes separated by a thin border.
Don't see the styled layout?
- Make sure both src/index.html and src/styles.css are saved. Unsaved changes won't appear in the app window.
- Check that the <link rel="stylesheet" href="styles.css" /> line is present in your HTML <head>. Without it, the CSS file won't load.
- If the window opened but shows the old template, close the window, stop the terminal process with Ctrl+C, and run npm run tauri dev again.
Why isn't my Tauri app showing the new layout?
✔️ Awesome, I've got everything!
Great. Keep the dev server running for the next substep.
ⓧ I'd like to double check the full code
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
height: 100vh;
overflow: hidden;
}
.app {
display: flex;
height: 100vh;
}
.sidebar {
width: 220px;
background: #1e1e2e;
color: #cdd6f4;
display: flex;
flex-direction: column;
border-right: 1px solid #313244;
}
.sidebar-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid #313244;
}
.sidebar-header h2 {
font-size: 16px;
font-weight: 600;
}
#new-note-btn {
background: #89b4fa;
color: #1e1e2e;
border: none;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
}
#new-note-btn:hover {
background: #74c7ec;
}
#notes-list {
list-style: none;
overflow-y: auto;
flex: 1;
}
#notes-list li {
padding: 12px 16px;
cursor: pointer;
border-bottom: 1px solid #313244;
font-size: 14px;
}
#notes-list li:hover {
background: #313244;
}
#notes-list li.active {
background: #45475a;
}
.editor-area {
flex: 1;
display: flex;
}
.editor-pane {
flex: 1;
display: flex;
border-right: 1px solid #e0e0e0;
}
#editor {
width: 100%;
height: 100%;
padding: 20px;
border: none;
resize: none;
font-family: "Cascadia Code", "Fira Code", "Consolas", monospace;
font-size: 14px;
line-height: 1.6;
outline: none;
background: #fafafa;
}
.preview-pane {
flex: 1;
overflow-y: auto;
padding: 20px;
background: #ffffff;
}
#preview {
font-size: 14px;
line-height: 1.8;
color: #333;
}
#preview h1, #preview h2, #preview h3 {
margin-top: 16px;
margin-bottom: 8px;
}
#preview p {
margin-bottom: 12px;
}
#preview code {
background: #f0f0f0;
padding: 2px 6px;
border-radius: 3px;
font-family: "Cascadia Code", "Fira Code", "Consolas", monospace;
font-size: 13px;
}
#preview pre {
background: #f0f0f0;
padding: 16px;
border-radius: 6px;
overflow-x: auto;
margin-bottom: 12px;
}
#preview pre code {
background: none;
padding: 0;
}
Add markdown rendering
The layout looks good, but the preview pane is empty. You need a library that converts raw markdown text into HTML so the preview updates as you type.
- Stop the dev server by pressing Ctrl+C in your terminal.
- Install the marked library by running this command:
npm install marked@15.0.7
Why marked?
The marked library is a lightweight markdown parser with zero dependencies. It takes a string of markdown and returns an HTML string you can inject into the DOM.
- Open src/main.ts in VS Code.
- Select all the existing content and replace it with this code:
import { marked } from "marked";
// Grab references to the editor textarea and preview div
const editor = document.getElementById("editor") as HTMLTextAreaElement;
const preview = document.getElementById("preview") as HTMLDivElement;
// Re-render the preview every time the user types
editor.addEventListener("input", () => {
preview.innerHTML = marked.parse(editor.value) as string;
});
What does this code do?
- It imports the marked function from the library you just installed.
- It grabs DOM references to the editor textarea and the preview div using their IDs.
- On every keystroke in the editor, it converts the current text to HTML with marked.parse() and injects the result into the preview pane.
- Save src/main.ts.
- Restart the dev server by running:
npm run tauri dev
- Once the app window opens, click inside the editor pane (the left area with the placeholder text).
- Type # Hello World and press Enter.
You should see a large "Hello World" heading appear in the preview pane on the right. As you continue typing markdown (try **bold** or - list item), the preview updates instantly.
Preview not updating?
- Check that npm install marked@15.0.7 completed without errors. Look for a marked entry in package.json under dependencies.
- Verify the import line says import { marked } from "marked" with curly braces around marked. Missing braces will cause a runtime error.
- If the terminal shows a compile error, make sure you stopped the old dev server before installing the package, then restarted with npm run tauri dev.
Why isn't my markdown preview rendering in the Tauri app?
✔️ Awesome, I've got everything!
Great. Double check you've saved all three files before moving on.
ⓧ I'd like to double check the full code
import { marked } from "marked";
const editor = document.getElementById("editor") as HTMLTextAreaElement;
const preview = document.getElementById("preview") as HTMLDivElement;
editor.addEventListener("input", () => {
preview.innerHTML = marked.parse(editor.value) as string;
});
Your notes app now has a fully styled split-pane interface with live markdown rendering. Next up, you'll write Rust commands that let the app save and load notes from the filesystem.
Write Rust Commands for File Operations
Your UI is ready, but clicking buttons does nothing yet. The frontend needs a backend that can read and write files on the local filesystem.
In this step, you will write Rust functions that handle all file operations for your notes app. These functions are exposed to the frontend through Tauri's command system, which lets JavaScript call Rust code over a secure bridge.
In this step, get ready to:
- Add serialization dependencies to your Rust project.
- Write four Rust commands for listing, saving, loading, and deleting notes.
- Configure file system permissions so your commands can access the app data directory.
Add serde dependencies to Cargo.toml
Tauri commands that accept arguments or return structured data need to serialize and deserialize values. The serde and serde_json crates handle this conversion between Rust types and JSON.
- In VS Code, open src-tauri/Cargo.toml.
- Find the [dependencies] section.
- Add these two lines below the existing tauri dependency:
serde = { version = "1", features = ["derive"] }
serde_json = "1"
What do these dependencies do?
The serde crate is Rust's standard serialization framework. The derive feature lets you annotate structs so they automatically convert to and from JSON.
The serde_json crate provides the JSON-specific encoding layer. Tauri uses both crates behind the scenes when passing data between your Rust commands and the JavaScript frontend.
- Save Cargo.toml.
Seeing red squiggles in Cargo.toml?
Make sure the new lines are inside the [dependencies] section, not above it or inside [build-dependencies]. Each dependency goes on its own line.
Help me debug my Cargo.toml
Write the Rust commands in lib.rs
Tauri's #[tauri::command] annotation turns a regular Rust function into something your JavaScript code can call. Each command you write handles one file operation.
Why custom Rust commands instead of a file system plugin?
Tauri offers an fs plugin that lets JavaScript access the file system directly. Writing custom Rust commands instead teaches you the foundational Tauri pattern: defining Rust functions, accepting typed arguments, returning results, and registering commands with the app builder.
This pattern applies to any backend logic you need in a Tauri app, from database queries to system calls. The file system plugin bypasses Rust entirely, which would miss the primary learning objective.
- Open src-tauri/src/lib.rs in VS Code.
- Select all existing code and delete it.
- Paste the following code that imports the standard library file module and creates the list_notes command:
use std::fs;
use tauri::Manager;
#[tauri::command]
fn list_notes(app_handle: tauri::AppHandle) -> Result<Vec<String>, String> {
// Get the app's data directory path
let app_dir = app_handle
.path()
.app_data_dir()
.map_err(|e| e.to_string())?;
// Create the directory if it doesn't exist yet
if !app_dir.exists() {
fs::create_dir_all(&app_dir).map_err(|e| e.to_string())?;
}
// Read all .md files and collect their names
let entries = fs::read_dir(&app_dir).map_err(|e| e.to_string())?;
let mut notes: Vec<String> = entries
.filter_map(|entry| {
let entry = entry.ok()?;
let path = entry.path();
if path.extension()?.to_str()? == "md" {
Some(entry.file_name().to_string_lossy().to_string())
} else {
None
}
})
.collect();
notes.sort();
Ok(notes)
}
What does this code do?
The list_notes function accepts an app_handle parameter, which Tauri injects automatically. It uses app_handle.path().app_data_dir() to find the platform-specific folder where your app stores data.
On Windows, this resolves to C:\Users\{username}\AppData\Roaming\com.markdown-notes.app\. The function creates this directory if it does not exist, then scans for .md files and returns their names as a sorted list.
The Result<Vec<String>, String> return type tells Tauri that this command can either succeed with a list of strings or fail with an error message.
- Add the save_note command below list_notes:
#[tauri::command]
fn save_note(app_handle: tauri::AppHandle, filename: String, content: String) -> Result<(), String> {
// Get the app's data directory path
let app_dir = app_handle
.path()
.app_data_dir()
.map_err(|e| e.to_string())?;
// Create the directory if it doesn't exist yet
if !app_dir.exists() {
fs::create_dir_all(&app_dir).map_err(|e| e.to_string())?;
}
// Write the content to the file
let file_path = app_dir.join(&filename);
fs::write(file_path, content).map_err(|e| e.to_string())?;
Ok(())
}
What does save_note do?
This command takes a filename and content string from the frontend, then writes the content to a file in the app data directory. It follows the same pattern as list_notes: get the app directory, ensure it exists, then perform the file operation.
- Add the load_note and delete_note commands below save_note:
#[tauri::command]
fn load_note(app_handle: tauri::AppHandle, filename: String) -> Result<String, String> {
// Get the app's data directory path
let app_dir = app_handle
.path()
.app_data_dir()
.map_err(|e| e.to_string())?;
// Read the file content and return it
let file_path = app_dir.join(&filename);
fs::read_to_string(file_path).map_err(|e| e.to_string())
}
#[tauri::command]
fn delete_note(app_handle: tauri::AppHandle, filename: String) -> Result<(), String> {
// Get the app's data directory path
let app_dir = app_handle
.path()
.app_data_dir()
.map_err(|e| e.to_string())?;
// Remove the file from disk
let file_path = app_dir.join(&filename);
fs::remove_file(file_path).map_err(|e| e.to_string())
}
What do these commands do?
- load_note takes a filename and returns the file's text content as a String.
- delete_note takes a filename and removes the file from disk using fs::remove_file.
All four commands follow the same pattern: get the app data directory, build the file path, perform the operation, and convert any errors to strings.
- Add the run() function that registers all four commands with the Tauri app builder. Paste this below delete_note:
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
// Register all four commands so JavaScript can call them
.invoke_handler(tauri::generate_handler![list_notes, save_note, load_note, delete_note])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
What does generate_handler do?
The tauri::generate_handler! macro creates a dispatch table that maps command names to their Rust implementations. When JavaScript calls invoke("list_notes"), Tauri looks up list_notes in this table and runs the corresponding function.
Any command not listed in generate_handler! will not be accessible from the frontend.
- Save lib.rs.
Seeing compile errors in the terminal?
Make sure you deleted all the original scaffold code before pasting. The file should start with use std::fs; and end with the closing brace of the run() function.
If you see an error about Manager not being found, confirm the second line is use tauri::Manager; with a capital M.
Help me debug my lib.rs
✔️ Awesome, I've got everything!
Great. Double check you have saved both Cargo.toml and lib.rs.
ⓧ I'd like to double check the full code
Here is the complete src-tauri/src/lib.rs file:
use std::fs;
use tauri::Manager;
#[tauri::command]
fn list_notes(app_handle: tauri::AppHandle) -> Result<Vec<String>, String> {
let app_dir = app_handle
.path()
.app_data_dir()
.map_err(|e| e.to_string())?;
if !app_dir.exists() {
fs::create_dir_all(&app_dir).map_err(|e| e.to_string())?;
}
let entries = fs::read_dir(&app_dir).map_err(|e| e.to_string())?;
let mut notes: Vec<String> = entries
.filter_map(|entry| {
let entry = entry.ok()?;
let path = entry.path();
if path.extension()?.to_str()? == "md" {
Some(entry.file_name().to_string_lossy().to_string())
} else {
None
}
})
.collect();
notes.sort();
Ok(notes)
}
#[tauri::command]
fn save_note(app_handle: tauri::AppHandle, filename: String, content: String) -> Result<(), String> {
let app_dir = app_handle
.path()
.app_data_dir()
.map_err(|e| e.to_string())?;
if !app_dir.exists() {
fs::create_dir_all(&app_dir).map_err(|e| e.to_string())?;
}
let file_path = app_dir.join(&filename);
fs::write(file_path, content).map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
fn load_note(app_handle: tauri::AppHandle, filename: String) -> Result<String, String> {
let app_dir = app_handle
.path()
.app_data_dir()
.map_err(|e| e.to_string())?;
let file_path = app_dir.join(&filename);
fs::read_to_string(file_path).map_err(|e| e.to_string())
}
#[tauri::command]
fn delete_note(app_handle: tauri::AppHandle, filename: String) -> Result<(), String> {
let app_dir = app_handle
.path()
.app_data_dir()
.map_err(|e| e.to_string())?;
let file_path = app_dir.join(&filename);
fs::remove_file(file_path).map_err(|e| e.to_string())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![list_notes, save_note, load_note, delete_note])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
Configure permissions for file system access
Tauri's permission model controls what each window is allowed to do. Your commands use the app data directory, so you need to grant file system permissions for reading and writing to that location.
- Open src-tauri/capabilities/default.json.
- Find the permissions array.
- Add "fs:default" and "fs:allow-app-write-recursive" to the end of the array, so the full file looks like this:
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"path:default",
"event:default",
"window:default",
"app:default",
"image:default",
"resources:default",
"menu:default",
"tray:default",
"fs:default",
"fs:allow-app-write-recursive"
]
}
What do these permissions do?
- fs:default enables read access to the app's data directories and their subdirectories.
- fs:allow-app-write-recursive grants full write access to the app's data folders and all subdirectories.
Together, these give your Rust commands the ability to create, read, update, and delete files inside the app data directory. Without them, file operations would be blocked at runtime.
- Save default.json.
- Verify everything compiles by running:
npm run tauri dev
- Wait for the Rust compilation to finish. The app window should open showing your notes UI from the previous step.
- Close the window to stop the dev server.
Compilation errors when running tauri dev?
If you see an error mentioning serde or serde_json, double check that the lines you added to Cargo.toml are inside the [dependencies] section and use the exact formatting shown above.
If you see a permissions-related error, confirm that default.json has valid JSON with no trailing commas after the last item in the permissions array.
Help me debug my compilation errors
Your Rust backend is in place. All four file operations compile and the app window opens without errors. Next up, you will connect these commands to the frontend so clicking buttons actually creates and loads notes.
Connect the Frontend to Rust Commands
Your Rust commands are ready to list, save, load, and delete notes. The backend can handle file operations, but the frontend has no way to reach it yet.
In this step, you will connect your TypeScript frontend to those Rust commands using Tauri's invoke pattern. Every button click and keystroke in the UI will trigger a call across the IPC boundary into Rust, and the results flow back to update the interface.
In this step, get ready to:
- Write TypeScript wrapper functions that call each Rust command via invoke.
- Wire up the sidebar, editor, and new-note button to create, load, and auto-save notes.
- Test the full flow by creating and editing notes in the running app.
Write the TypeScript integration layer
Tauri's invoke function sends a message from JavaScript to Rust and returns a Promise with the result. You pass the command name as a string and any arguments as a JSON object with camelCase keys.
What is the invoke pattern?
When you call invoke("list_notes") in JavaScript, Tauri serializes the request and sends it to the Rust backend via IPC. The Rust function runs, and its return value is serialized back as JSON to resolve the Promise.
Arguments use camelCase in JavaScript but snake_case in Rust. Tauri auto-converts between them. So { filename, content } in JS maps to filename: String, content: String in Rust.
- Open src/main.ts in VS Code.
- Select all the existing code and delete it.
- Add the imports, state variables, and DOM references by pasting this code:
import { invoke } from "@tauri-apps/api/core";
import { marked } from "marked";
// Track which note is currently open and the auto-save timer
let currentNote: string | null = null;
let saveTimeout: ReturnType<typeof setTimeout> | null = null;
// Cache references to the DOM elements we'll interact with
const editor = document.getElementById("editor") as HTMLTextAreaElement;
const preview = document.getElementById("preview") as HTMLDivElement;
const notesList = document.getElementById("notes-list") as HTMLUListElement;
const newNoteBtn = document.getElementById("new-note-btn") as HTMLButtonElement;
What does this code do?
- invoke is imported from @tauri-apps/api/core. This is the function that bridges JavaScript to Rust commands.
- currentNote tracks which file is open so auto-save knows where to write.
- saveTimeout holds the debounce timer so rapid keystrokes don't flood the filesystem with writes.
- The four DOM references cache elements so you don't re-query the DOM on every interaction.
- Below the DOM references, add the four invoke wrapper functions:
// Each function calls invoke() with a Rust command name
// Arguments use camelCase here - Tauri converts to snake_case for Rust
async function listNotes(): Promise<string[]> {
return await invoke<string[]>("list_notes");
}
async function saveNote(filename: string, content: string): Promise<void> {
await invoke("save_note", { filename, content });
}
async function loadNote(filename: string): Promise<string> {
return await invoke<string>("load_note", { filename });
}
async function deleteNote(filename: string): Promise<void> {
await invoke("delete_note", { filename });
}
What does this code do?
Each function is a thin TypeScript wrapper around invoke. The string you pass (like "list_notes") must match the Rust function name exactly.
The generic type parameter (invoke<string[]>) tells TypeScript what shape the Rust return value will have after deserialization.
You won't see a change yet. The next section wires these functions into the UI.
Wire up the UI interactions
The wrapper functions can talk to Rust, but nothing calls them yet. You need functions that render the sidebar list, open a note into the editor, handle live preview, and auto-save on a delay.
- Below the deleteNote function in src/main.ts, add the sidebar rendering and note-opening logic:
// Fetch all notes from Rust and render them as sidebar list items
async function refreshNotesList(): Promise<void> {
const notes = await listNotes();
notesList.innerHTML = "";
for (const note of notes) {
const li = document.createElement("li");
li.textContent = note.replace(".md", "");
li.dataset.filename = note;
if (note === currentNote) {
li.classList.add("active");
}
li.addEventListener("click", () => openNote(note));
notesList.appendChild(li);
}
}
// Load a note's content from disk and display it in the editor
async function openNote(filename: string): Promise<void> {
currentNote = filename;
const content = await loadNote(filename);
editor.value = content;
updatePreview(content);
refreshNotesList();
}
What does this code do?
- refreshNotesList() calls the Rust list_notes command, clears the sidebar, and creates a clickable <li> for each .md file. The currently open note gets the active CSS class.
- openNote() sets currentNote, fetches the file content from Rust, populates the textarea, updates the markdown preview, and re-renders the sidebar to highlight the active note.
- Below the openNote function, add the preview rendering, debounce logic, event listeners, and initialization call:
// Convert markdown text to HTML and display in the preview pane
function updatePreview(content: string): void {
preview.innerHTML = marked.parse(content) as string;
}
// Wait 1 second after the last keystroke before saving to disk
function debounceSave(): void {
if (saveTimeout) {
clearTimeout(saveTimeout);
}
saveTimeout = setTimeout(async () => {
if (currentNote) {
await saveNote(currentNote, editor.value);
}
}, 1000);
}
// Update preview and trigger auto-save on every keystroke
editor.addEventListener("input", () => {
updatePreview(editor.value);
debounceSave();
});
// Prompt for a name, create the file, then open it
newNoteBtn.addEventListener("click", async () => {
const name = prompt("Note name:");
if (name) {
const filename = name.endsWith(".md") ? name : `${name}.md`;
await saveNote(filename, "");
await openNote(filename);
await refreshNotesList();
}
});
// Load the sidebar on app start
refreshNotesList();
What does this code do?
- updatePreview() converts the textarea's markdown into HTML using the marked library and renders it in the preview pane.
- debounceSave() waits 1 second after the last keystroke before writing to disk. This prevents a file write on every single character.
- The input event listener updates the live preview and queues an auto-save.
- The click listener on the new-note button prompts for a name, creates an empty file via Rust, then opens it.
- The final refreshNotesList() call runs on page load to populate the sidebar immediately.
- Save src/main.ts.
✔️ Awesome, I've got everything!
Great. Double check you've saved the file before moving on.
ⓧ I'd like to double check the full code
Here is the complete src/main.ts file:
import { invoke } from "@tauri-apps/api/core";
import { marked } from "marked";
let currentNote: string | null = null;
let saveTimeout: ReturnType<typeof setTimeout> | null = null;
const editor = document.getElementById("editor") as HTMLTextAreaElement;
const preview = document.getElementById("preview") as HTMLDivElement;
const notesList = document.getElementById("notes-list") as HTMLUListElement;
const newNoteBtn = document.getElementById("new-note-btn") as HTMLButtonElement;
async function listNotes(): Promise<string[]> {
return await invoke<string[]>("list_notes");
}
async function saveNote(filename: string, content: string): Promise<void> {
await invoke("save_note", { filename, content });
}
async function loadNote(filename: string): Promise<string> {
return await invoke<string>("load_note", { filename });
}
async function deleteNote(filename: string): Promise<void> {
await invoke("delete_note", { filename });
}
async function refreshNotesList(): Promise<void> {
const notes = await listNotes();
notesList.innerHTML = "";
for (const note of notes) {
const li = document.createElement("li");
li.textContent = note.replace(".md", "");
li.dataset.filename = note;
if (note === currentNote) {
li.classList.add("active");
}
li.addEventListener("click", () => openNote(note));
notesList.appendChild(li);
}
}
async function openNote(filename: string): Promise<void> {
currentNote = filename;
const content = await loadNote(filename);
editor.value = content;
updatePreview(content);
refreshNotesList();
}
function updatePreview(content: string): void {
preview.innerHTML = marked.parse(content) as string;
}
function debounceSave(): void {
if (saveTimeout) {
clearTimeout(saveTimeout);
}
saveTimeout = setTimeout(async () => {
if (currentNote) {
await saveNote(currentNote, editor.value);
}
}, 1000);
}
editor.addEventListener("input", () => {
updatePreview(editor.value);
debounceSave();
});
newNoteBtn.addEventListener("click", async () => {
const name = prompt("Note name:");
if (name) {
const filename = name.endsWith(".md") ? name : `${name}.md`;
await saveNote(filename, "");
await openNote(filename);
await refreshNotesList();
}
});
refreshNotesList();
Test the full flow
The frontend and backend are now connected. Time to confirm everything works end-to-end.
- Start the dev server by running this command:
npm run tauri dev
What should I see?
After Rust recompiles (this may take a moment), a native window opens showing the app with an empty sidebar and the editor placeholder text.
- Click the + New button in the sidebar header.
- Type a name in the prompt (for example hello) and press Enter.
- Type some markdown in the editor, for example # Hello World.
- Confirm the preview pane on the right updates in real-time as you type.
- Click + New again and create a second note.
- Click back on your first note in the sidebar and confirm the content you typed is still there.
Your notes persist across navigation because Rust is writing .md files to the app data directory on disk.
Notes not appearing in the sidebar?
- Check the terminal for Rust compilation errors. A typo in lib.rs will prevent the backend from starting.
- Make sure src-tauri/capabilities/default.json includes "fs:default" and "fs:allow-app-write-recursive". Without these permissions, Rust cannot read or write files.
- Open the browser DevTools inside the Tauri window (Ctrl+Shift+I) and check the console for errors like "invoke" or "permission denied".
Help me debug why my notes aren't loading in the sidebar.
- Close the app window to stop the dev server when you are finished testing.
Your frontend and backend are fully connected. Next up, you will package everything into a Windows installer that anyone can run.
Build and Package the Windows Installer
Your app is fully functional in dev mode. You can create notes, type markdown, see a live preview, and have everything persist to disk.
Now it is time to turn all of that into a real Windows application. In this step, you will compile the Rust backend, bundle the frontend, and produce an NSIS installer that anyone can run to install your app.
In this step, get ready to:
- Build the production app with a single command.
- Locate and test the generated Windows installer.
- Review the final binary size and architecture.
Build the production app
The npm run tauri build command compiles your Rust code in release mode (with optimizations), bundles your frontend assets, and generates a Windows installer. The first time you run it, Rust compiles every dependency from scratch.
- Start the production build by running this command:
npm run tauri build
What does this command do?
This single command triggers a chain of steps. First, your TypeScript frontend is compiled and bundled into static HTML, CSS, and JavaScript. Then the Rust compiler builds your backend in release mode with full optimizations.
Finally, Tauri's bundler wraps everything together and produces an NSIS installer for Windows. The entire process runs without any additional configuration.
This takes a while
The first production build compiles all Rust dependencies from source. This typically takes 3-10 minutes depending on your machine. Subsequent builds are much faster because compiled dependencies are cached.
- Wait for the build to finish. You will see Finished release [optimized] target(s) in the terminal output, followed by the path to your generated installer.
Build failing with a linker error?
Make sure the Microsoft C++ Build Tools are installed with the "Desktop development with C++" workload. Without the Windows linker, Rust cannot produce the final executable.
If you see permission errors, close any running instances of your app (from npm run tauri dev) and try again.
help me fix a Tauri build error on Windows
Locate and test the installer
Once the build finishes, Tauri places the installer in a specific folder inside your project. You will now find it, run it, and confirm the installed app works correctly.
- Open File Explorer and navigate to your markdown-notes project folder on your Desktop.
- Open the following path: src-tauri > target > release > bundle > nsis.
- You should see a file named markdown-notes_0.1.0_x64-setup.exe.
- Double-click the installer to run it.
- Follow the installation prompts to install the app on your machine.
- Once installed, launch Markdown Notes from your Start menu.
- Confirm notes you created during development are still accessible. They are stored separately in your app data directory, so the installer does not affect them.
Where are my notes stored?
Your notes live at C:\Users\{username}\AppData\Roaming\com.markdown-notes.app\. Both the dev version and the installed version share this directory, so all your notes carry over automatically.
Installer not launching?
Windows SmartScreen may block the installer because it is unsigned. Click More info, then Run anyway to proceed.
If the installed app opens but shows a blank window, confirm that WebView2 is installed on your system. Windows 10 1803+ and Windows 11 include it by default.
help me troubleshoot my Tauri installer not working
Review what was built
Take a moment to appreciate what just happened. You produced a native Windows desktop application from a web frontend and a Rust backend.
- Right-click markdown-notes_0.1.0_x64-setup.exe in File Explorer and select Properties to check the file size.
- Note that the installer is typically 2-10MB. Compare that to an Electron app, which bundles an entire Chromium browser and is usually 100MB or larger.
Why is the app so small?
Tauri does not ship a browser engine with your app. Instead, it uses the WebView2 runtime that is already installed on every modern Windows machine. Your installer only contains the compiled Rust binary and your bundled frontend assets.
This is a core advantage of Tauri over Electron. Smaller downloads, lower memory usage, and faster startup times.
Secret mission
Add a System Tray Icon
Your notes app works great, but what if you could access it without switching windows? Add a system tray icon that lets you quickly create a new note, show or hide the app, and quit cleanly from the tray context menu.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- The markdown-notes project directory on your Desktop.
- Compiled binary and NSIS installer at src-tauri/target/release/bundle/.
- Notes stored in your app data directory (C:\Users\{username}\AppData\Roaming\com.markdown-notes.app\).
- The installed app, if you ran the markdown-notes_0.1.0_x64-setup.exe installer.
Keep everything running
No action needed. Choose this if you are still actively building or want to keep developing your markdown notes app.
- Your project files, compiled binary, and saved notes all remain on disk.
- Run npm run tauri dev from the markdown-notes directory any time you want to pick up where you left off.
Pause - I'll come back to this later
Everything is local, so there is nothing to pause or shut down. No processes run in the background unless you have the app open.
- Close any running Tauri dev server by pressing Ctrl+C in your terminal.
- Your project files and notes will still be there when you come back.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow these steps to clean up everything.
- If you ran the installer, uninstall the app first. Open Settings, select Apps, then Apps & features. Find markdown-notes in the list and click Uninstall.
- Delete the project folder by running this in your terminal:
cd ~/Desktop
rmdir /s /q markdown-notes
- Delete your saved notes from the app data directory:
rmdir /s /q "$env:APPDATA\com.markdown-notes.app"
- Confirm both folders are gone by checking that neither markdown-notes on your Desktop nor com.markdown-notes.app in your AppData\Roaming folder exist.
Nice Work!
Nice Work!
You just built a fully functional markdown notes app that runs as a native desktop application. From writing Rust backend commands to packaging a Windows installer, you now have hands-on experience with the full Tauri development lifecycle.
You've learned how to:
- Build a native desktop app with Tauri v2 and a vanilla TypeScript frontend, using Rust on the backend.
- Write Rust commands with #[tauri::command] and call them from JavaScript using the invoke pattern for frontend-to-backend communication.
- Package the finished app into a Windows .exe installer using NSIS bundling, ready to distribute.
- Secret Mission: Added a system tray icon with show/hide and quick note creation capabilities.
Ready to quiz yourself?