Native App
Tauri desktop and mobile application. Next.js frontend with Rust backend.
A Next.js frontend statically exported into a Tauri 2 webview with a Rust backend. Produces desktop binaries for Windows, macOS, and Linux, plus mobile builds for Android and iOS.
Directory Structure
Architecture
- Next.js pages are statically exported to
dist/ - Tauri loads the static files into the OS-native webview
- Frontend calls Rust functions via
invoke()over IPC - Rust commands return JSON to the webview
Tauri Configuration
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "tntstack",
"version": "x.x.x",
"identifier": "com.tntstack.app",
"build": {
"beforeDevCommand": "pnpm dev",
"devUrl": "http://localhost:3000",
"beforeBuildCommand": "pnpm build",
"frontendDist": "../dist"
},
"app": {
"windows": [{ "title": "tntstack", "width": 800, "height": 600 }],
"security": { "csp": null }
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}In dev mode, Tauri runs pnpm dev and points the webview at localhost:3000. In production, it runs pnpm build and loads static files from dist/.
Rust Backend
use serde::Serialize;
#[derive(Serialize)]
struct GreetResponse {
message_key: String,
name: String,
source: String,
}
#[tauri::command]
fn greet(name: &str) -> GreetResponse {
GreetResponse {
message_key: "successGreeting".to_string(),
name: name.to_string(),
source: "Tauri".to_string(),
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![greet])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}The greet command returns a GreetResponse struct with a message_key field. The frontend resolves this key to a translated string via next-intl. Register plugins with .plugin() and IPC commands with invoke_handler. The mobile_entry_point attribute makes the same entry point work on both desktop and mobile.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
tntstack_lib::run()
}The crate name comes from Cargo.toml's [lib] name field. After scaffolding with the CLI, this will match your project name.
| Crate | Role |
|---|---|
tauri | Core runtime (with devtools feature) |
tauri-plugin-opener | Open URLs and files with the OS default |
serde / serde_json | JSON serialization for IPC |
tauri-build | Build-time code generation |
The custom-protocol feature flag switches between dev server and static file loading in production builds.
Capabilities
Tauri 2 gates every API behind explicit permissions per window:
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "enables the default permissions",
"windows": ["main"],
"permissions": ["core:default", "opener:default"]
}| Permission | What it grants |
|---|---|
core:default | Window, event, and app lifecycle operations |
opener:default | Open URLs and files with system apps |
Adding a new Tauri plugin? You'll need to register it in lib.rs and add its permission here.
Next.js Configuration
import type { NextConfig } from "next";
import createNextIntlPlugin from "@workspace/i18n/plugin";
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
const nextConfig: NextConfig = {
reactStrictMode: true,
output: "export",
trailingSlash: true,
images: {
unoptimized: true,
},
distDir: "dist",
transpilePackages: ["@workspace/ui", "@workspace/core", "@workspace/i18n"],
};
export default withNextIntl(nextConfig);Native vs Web
| Native App | Web App | |
|---|---|---|
| Output | Static export (dist/) | Server-rendered |
| Images | unoptimized: true | Server-optimized |
| API Routes | Not available | Yes |
| SSR / ISR | Not available | Yes |
| i18n plugin | createNextIntlPlugin("./src/i18n/request.ts") | createNextIntlPlugin() |
| Extra plugins | None | withMDX, withSerwist |
| Server logic | Rust commands via IPC | API routes |
Static export means no API routes, no SSR, no ISR. If you need server-side logic in the native app, put it in Rust commands instead.