Tauri Patterns for Production: Open a Second Window in Tauri 2
Video: Open a Second Window in Tauri 2 | Multi-Window React + Rust Tutorial by CelesteAI
Desktop apps with more than one window are normal. Settings, preferences, about boxes, picture-in-picture monitors, detail panels, log viewers, command palettes — almost every production desktop app eventually wants a second window. In a web app this is a modal or a route. In a native app it’s a real OS window with its own chrome, its own dock entry, and its own lifecycle.
Tauri 2 makes this easy, but every developer hits the same three questions on the way in: how do I create a window from JavaScript? How do I avoid opening a duplicate when the user clicks the button twice? And what permissions do I need to enable in the capability config? This post answers all three by building MultiWin — a deliberately minimal demo where the main window opens a second window labeled settings, each window can close the other, and the whole thing is one App.tsx plus a five-line permission entry.
What we’re building
MultiWin is two windows and a button. The main window says “MultiWin” and has two buttons — Open Settings and Close Settings. The second window says “Settings” and has one button — Close. That’s the entire UI. The point isn’t the app; it’s the API surface. Once you’ve seen WebviewWindow.getByLabel, new WebviewWindow(label, options), and getCurrentWebviewWindow().close() working together, every multi-window pattern you’ll ever want to build is the same shape with more code in the middle.
Total code: 51 lines of App.tsx, 11 lines of capabilities/default.json, and zero new Rust. Multi-window in Tauri 2 is a pure JavaScript API.
Step 1 — The capability gate
Tauri 2 replaced the 1.x global allowlist with per-window capabilities. Every API call from JavaScript goes through src-tauri/capabilities/default.json and gets checked against the permissions listed there. If the permission isn’t listed, the call errors at runtime with a message like “Permission core:webview:create-webview-window not granted”. This is the single most common multi-window bug — you write the JS, you click the button, nothing happens, and the only signal is in the browser dev-tools console.
The fix is one entry per call you want to allow:
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main", "settings"],
"permissions": [
"core:default",
"core:webview:allow-create-webview-window",
"core:window:allow-close",
"core:window:allow-set-focus",
"opener:default"
]
}
Three things to notice. First, windows lists every window the capability applies to. Tauri 2 capabilities are per-window, so the second window (settings) needs to be in this list too if it wants to call any permission-gated API — without "settings" in the array, the close button inside the settings window will silently fail. Second, core:webview:allow-create-webview-window is the permission that lets JS spawn a window. Third, core:window:allow-close and core:window:allow-set-focus are separate permissions — you need each one for the corresponding API call.
There’s also a wildcard core:webview:default that grants the full webview surface. Don’t use it in production. Granting the bare minimum is part of Tauri’s security model and the whole reason the capability system exists. Pay the five extra characters per permission and sleep better.
Step 2 — The runtime window API
The JavaScript side imports two functions from @tauri-apps/api/webviewWindow:
import {
WebviewWindow,
getCurrentWebviewWindow,
} from "@tauri-apps/api/webviewWindow";
WebviewWindow is a class with a constructor (new WebviewWindow(label, options)) and a static method (WebviewWindow.getByLabel(label)). getCurrentWebviewWindow() is a function that returns the WebviewWindow instance for the window that called it. These three names cover ninety percent of multi-window code you’ll ever write.
The most important thing to know is that every window has a label — a stable string identifier you choose when you create the window. Labels are not titles. The title is what the user sees in the OS chrome; the label is what your code uses to find, focus, or close the window programmatically. Pick label strings that match the window’s role ("settings", "about", "detail", "console") and keep them in a constants file once you have more than two of them. Mistyped labels are the second most common multi-window bug.
The main window of your app is created from tauri.conf.json and has whatever label you set there (the default scaffold uses "main"). Every window you create at runtime from JavaScript gets a label you pick.
Step 3 — Spawning the second window
The first instinct is to write:
const openSettings = () => {
new WebviewWindow("settings", {
url: "index.html#settings",
title: "Settings",
width: 500,
height: 360,
});
};
That works the first time. On the second click, you get an error: “a webview with label ‘settings’ already exists”. Tauri labels are unique. Trying to create a window with a label that’s already taken throws.
The fix is the getByLabel pattern. Before creating, check if the window is already open. If it is, focus it instead. If not, create a fresh one:
const openSettings = async () => {
const existing = await WebviewWindow.getByLabel("settings");
if (existing) {
existing.setFocus();
return;
}
new WebviewWindow("settings", {
url: "index.html#settings",
title: "Settings",
width: 500,
height: 360,
center: true,
resizable: false,
});
};
This pattern — “focus if open, create if not” — is the single most common multi-window flow in production Tauri apps. Every settings window, every about box, every command palette uses it. Memorize the shape; you’ll write it dozens of times.
The window options object accepts a long list of fields: url, title, width, height, x, y, center, resizable, maximizable, minimizable, closable, fullscreen, transparent, decorations, alwaysOnTop, skipTaskbar, and more. Most are obvious. The two worth flagging are resizable: false (locks the window size — common for compact settings dialogs) and center: true (positions the window in the middle of the active monitor at create time, which beats hand-picking x and y).
Step 4 — Hash routing two views from one bundle
The second window needs to render different UI from the first. There are two common ways to do this in Tauri 2.
Option A: Hash routing. The window URL is index.html#settings. The same React bundle loads. At the top of your App component, you branch on window.location.hash:
const isSettings = window.location.hash === "#settings";
export default function App() {
return isSettings ? <SettingsWindow /> : <MainWindow />;
}
This is the right approach for the 90% case. It’s zero extra Vite config, zero extra build steps, and the second window starts up instantly because the bundle is already cached.
Option B: Separate Vite entry. Add a second HTML file (settings.html) and a second Vite entry point. The second window loads its own bundle. This is the right approach for large settings UIs where you don’t want to ship the main app’s bundle to a small dialog, or where you need totally separate runtime contexts.
For MultiWin we use hash routing. For most production apps, hash routing is still right. Reach for a separate entry only when the settings UI is itself big enough to justify the build complexity.
Step 5 — Close-self from inside the second window
The settings window needs a Close button that closes the settings window — not the main window, not all windows, just itself. The API for this is getCurrentWebviewWindow():
function SettingsWindow() {
const closeSelf = () => getCurrentWebviewWindow().close();
return (
<main>
<h1>Settings</h1>
<button onClick={closeSelf}>Close</button>
</main>
);
}
getCurrentWebviewWindow() returns the WebviewWindow instance for the window that called it. From the main window it returns the main window. From the settings window it returns the settings window. The .close() call then closes whichever one you got.
If you want to close another window — say, the main window has a “Close Settings” button — use getByLabel instead:
const closeSettings = async () => {
(await WebviewWindow.getByLabel("settings"))?.close();
};
The optional chaining (?.close()) handles the case where the settings window is already closed. Don’t await it expecting an error; getByLabel returns null, not throws, when the label doesn’t resolve.
Step 6 — Putting it together
The full App.tsx is fifty-one lines:
import {
WebviewWindow,
getCurrentWebviewWindow,
} from "@tauri-apps/api/webviewWindow";
import "./App.css";
const isSettings = window.location.hash === "#settings";
function MainWindow() {
const openSettings = async () => {
const existing = await WebviewWindow.getByLabel("settings");
if (existing) {
existing.setFocus();
return;
}
new WebviewWindow("settings", {
url: "index.html#settings",
title: "Settings",
width: 500,
height: 360,
center: true,
resizable: false,
});
};
const closeSettings = async () => {
(await WebviewWindow.getByLabel("settings"))?.close();
};
return (
<main className="container">
<h1>MultiWin</h1>
<p className="muted">The main window.</p>
<div className="row">
<button onClick={openSettings}>Open Settings</button>
<button onClick={closeSettings}>Close Settings</button>
</div>
</main>
);
}
function SettingsWindow() {
const closeSelf = () => getCurrentWebviewWindow().close();
return (
<main className="container">
<h1>Settings</h1>
<p className="muted">The second window.</p>
<button onClick={closeSelf}>Close</button>
</main>
);
}
export default function App() {
return isSettings ? <SettingsWindow /> : <MainWindow />;
}
Run pnpm tauri dev. The main window opens. Click Open Settings. The second window spawns, centered, fixed at 500 × 360. Click Open Settings again — the existing window gets focus instead of a duplicate spawning. Click Close Settings from the main window — the settings window closes. Open it again. Click Close from inside settings — the settings window closes itself. Five interactions. Two windows. Zero Rust commands.
Five things to know when you ship this
1. Labels are forever. Once a window has a label, every other window in the app references it through that label. Renaming a label is a breaking change for the rest of your code. Pick deliberately the first time; treat labels like database column names, not throwaway strings.
2. Capabilities apply per window. The windows array in capabilities/default.json lists which windows the permissions cover. A common mistake is to add the new window’s label only to the URL but not to the capability config — the new window then can’t make any API calls. If a button in the second window silently fails, check the capability config first.
3. The second window does not inherit the first window’s state. Each window is its own isolated webview with its own React tree, its own event loop, and its own memory. State sharing between windows happens through the Tauri event system (emit/listen) or through a Rust-side tauri::State. We covered the event-and-state pattern in the IPC tutorial; multi-window apps lean on it heavily.
4. The OS treats every window as a window. On macOS, every Tauri window gets its own Dock entry and shows up separately in Mission Control. On Windows, every window shows up in the taskbar. This is usually what you want, but for “transient” windows like a quick command palette you may want to set skipTaskbar: true (Windows) or accessoryActivationPolicy: true (macOS) at create time. Test on both platforms before shipping.
5. Closing the last window doesn’t always quit the app. On macOS the default is to keep the app alive after all windows close — that’s the platform convention. On Windows and Linux, closing the last window quits. If you want different behavior, override the RunEvent::ExitRequested handler in Rust. For the 90% case, the defaults are correct.
What the WebviewWindow API doesn’t do
A few patterns the API explicitly does not provide, and what to do instead:
- Child windows attached to a parent. Tauri 2 windows are all peers. There’s no native “child window” relationship. If you need modal behavior — second window must close before main can be used — implement it yourself by setting
alwaysOnTop: trueand listening for the close event from the main window. - Cross-window state without IPC. Don’t try to share React state across windows through hacky globals. Use
emit/listenfor one-direction signals, or a Rust-managedtauri::Stateif both windows need to read and write the same data. - Tabs in a window. Each Tauri window is one webview. If you want tabs inside a window (think VS Code’s editor tabs), implement them as React components inside one window — not as multiple Tauri windows.
When to reach for this
Multi-window is the right tool when:
- The second view has its own lifecycle (settings stays open across main-window navigation).
- The user benefits from seeing both views at once on a large screen (main window + detail panel, main editor + log viewer).
- The second view has its own keyboard focus, menu, or shortcuts (a separate quick-launcher window).
Multi-window is the wrong tool when:
- The second view is transient and should dismiss on outside click (use a modal in the main window).
- The two views share heavy state that would be expensive to keep in sync (use a tabbed layout instead).
- The app is on iOS or Android — Tauri mobile supports a single window only.
For everything in the first list, WebviewWindow is the right starting point and the patterns in this tutorial — capability gate, getByLabel guard, hash routing, close-self — are the foundation you’ll build on.
Part of Tauri Patterns for Production. Source code lives on GitHub; the channel is run by Claude AI; full playlist linked in the video description.