Development Environment

Learn how to manage environment variables in a Tauri v2 development setup with React and Vite, covering .env files, frontend and backend variable access, and common pitfalls.

What Environment Variables Do in a Tauri App

A Tauri application combines a Rust backend with a web‑based frontend running inside a native webview. These two worlds operate in different contexts: the frontend is a browser‑like sandbox, while the Rust core has full access to the operating system. Environment variables bridge the gap between code and the outside world — they store configuration values like API URLs, feature flags, or debug switches outside of your source code.

Think of an environment variable as a labelled drawer that your app can open at build time or runtime. Instead of writing http://localhost:4000 directly in your React component, you store it in a variable called VITE_API_BASE. If the backend moves, you change the variable, not the code. This separation makes the app easier to port across developers, machines, and deployment stages.

The key insight for Tauri is that the frontend and backend handle environment variables differently:

  • Frontend (Vite) — Variables are embedded at build time. Vite replaces import.meta.env.VITE_SOMETHING with the actual string value during the bundling step. The frontend never sees live system environment variables; only the ones that Vite processes are baked into the JavaScript.
  • Backend (Rust) — The Rust process can read the real system environment at runtime via std::env::var. You can also load a .env file manually using a crate like dotenvy and make those values available to your Tauri commands.

Build‑time vs runtime:

The frontend gets variables at build time, while the Rust backend reads them at runtime. If you update a .env variable and refresh the frontend without rebuilding, the old value will still appear until you restart the dev server or run a new build.

Variables Tauri Sets Automatically During Development

When you run tauri dev, the CLI sets several environment variables that are passed to your hook commands — the scripts you define in tauri.conf.json under beforeDevCommand or beforeBuildCommand. These variables help you conditionally configure the frontend build or perform environment‑specific actions.

The most useful ones for development:

VariableDescription
TAURI_ENV_DEBUGtrue for dev and build --debug, false otherwise.
TAURI_ENV_TARGET_TRIPLEFull target triple, e.g., x86_64-pc-windows-msvc.
TAURI_ENV_ARCHCPU architecture, x86_64, aarch64, etc.
TAURI_ENV_PLATFORMTarget platform: windows, darwin, linux.
TAURI_ENV_PLATFORM_VERSIONBuild platform OS version.
TAURI_ENV_FAMILYPlatform family: unix or windows.

These are not automatically injected into your Rust code. They exist inside the Node.js process that runs your beforeDevCommand script. For example, in a Vite + React project your package.json script could look like this:

{
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build"
  }
}

If tauri.conf.json uses "beforeDevCommand": "npm run dev", the npm run dev process will have TAURI_ENV_DEBUG=true in its environment. Your vite.config.ts can read it via process.env.TAURI_ENV_DEBUG to adjust the dev server configuration, but for simple React apps you rarely need to touch this. Vite itself does not forward these Tauri‑specific variables to the browser; only variables prefixed with VITE_ end up in import.meta.env.

Accessing these variables from Rust:

The TAURI_ENV_* variables are not visible to the Rust application by default. If you need a similar switch inside your Rust code, use std::env::var("TAURI_ENV_DEBUG") only if you launch the binary with the variable set. A cleaner approach is to set your own environment variable before running tauri dev and read that from Rust.

Setting Up a .env File for Development

A .env file stores key‑value pairs in a simple format that tools can load into the process environment. Vite supports .env files natively: it loads .env.development, .env.production, and several others automatically, depending on the current mode. The Rust side does not automatically read .env files; you must add a crate like dotenvy if you want that.

Create a .env.development file at the root of your project:

VITE_API_BASE=http://localhost:8080
VITE_ENABLE_MOCK=true
MY_RUST_SECRET=dev-token-123

Notice the naming pattern:

  • VITE_ prefixed variables are exposed to the frontend. Anything without that prefix stays on the Node.js side and never reaches the browser.
  • The MY_RUST_SECRET variable is meant for the Rust backend; the frontend cannot see it (which is good — tokens should never leak to client code).

Vite prefix requirement:

Vite will only expose environment variables to your React code if they start with VITE_. Using REACT_APP_ or any other prefix will not work with Vite — they remain invisible to import.meta.env.

Reading Variables from the React Frontend

Once Vite processes .env.development, your React components can access the variables through the global import.meta.env object. The values are replaced at build time, so you can use them anywhere in your source code.

Create a small component that displays the API base URL and a mock flag:

import { useState, useEffect } from "react";
function App() {
  const apiBase = import.meta.env.VITE_API_BASE;
  const mockEnabled = import.meta.env.VITE_ENABLE_MOCK === "true";
  const [data, setData] = useState<string | null>(null);
  useEffect(() => {
    if (!mockEnabled) {
      fetch(`${apiBase}/status`)
        .then((res) => res.json())
        .then((json) => setData(JSON.stringify(json)))
        .catch(() => setData("fetch failed"));
    } else {
      setData('{ "status": "mocked" }');
    }
  }, []);
  return (
    <div>
      <h2>API Base: {apiBase}</h2>
      <p>Mock mode: {mockEnabled ? "on" : "off"}</p>
      <pre>{data ?? "loading..."}</pre>
    </div>
  );
}
export default App;

Vite replaces import.meta.env.VITE_API_BASE with the literal string "http://localhost:8080" during bundling. There is no runtime lookup — the string is hardcoded into the JavaScript that ships to the browser. This means you get dead‑code elimination: if VITE_ENABLE_MOCK is "true", the fetch branch can be completely removed by the minifier.

Everything working?:

After running npm run tauri dev, open the app. You should see the API base and the mock status from your .env.development file. If you change a variable, restart the dev server (Ctrl+C then npm run tauri dev) to pick up the new value. Vite does not hot‑reload environment variable changes.

Why Vite Embeds Variables at Build Time

Vite follows the same philosophy as most modern bundlers: only values that are explicitly marked as public (VITE_ prefix) get embedded. This protects you from accidentally shipping server secrets to the browser. Because the values are inlined, there’s no performance cost at runtime, and you can use them in conditional logic without worrying about the overhead of an environment lookup.

Reading Variables from the Rust Backend

The Rust part of your Tauri app runs as a native process. It has full access to the system’s environment variables through std::env::var. However, during development you won’t want to manually export a dozen variables in your terminal every time. The standard approach is to load a .env file with the dotenvy crate (the actively maintained successor to dotenv).

Add the dependency to your Cargo.toml:

[dependencies]
tauri = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
dotenvy = "0.15"

Now load the .env file at the top of your main function:

// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
    // Load .env from the project root (where Cargo.toml lives)
    dotenvy::dotenv().ok();
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![read_secret])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}
#[tauri::command]
fn read_secret() -> String {
    std::env::var("MY_RUST_SECRET").unwrap_or_else(|_| "not set".into())
}

The dotenvy::dotenv().ok() call reads the .env file that is next to Cargo.toml (or the current working directory) and pushes those key‑value pairs into the process environment. After that, std::env::var works as if you had exported them yourself.

The command read_secret simply returns the value. You can call it from the frontend to verify that the backend has loaded the variable:

import { invoke } from "@tauri-apps/api/core";
// Inside a component:
const [secret, setSecret] = useState("");
useEffect(() => {
  invoke<string>("read_secret").then(setSecret);
}, []);
// In JSX: <p>Backend secret: {secret}</p>

Watch the working directory:

During tauri dev, the working directory for the Rust process is the src-tauri folder. Your .env file must be placed at the project root for dotenvy to find it automatically if you use dotenv() without arguments. Alternatively, call dotenvy::from_filename("../.env.development") to point to a specific file.

The .cargo/config.toml Alternative

Some projects prefer to set environment variables directly in Cargo’s configuration rather than using a .env file. This is useful for variables that never change across environments, like compile‑time feature switches:

[env]
MY_RUST_SECRET = "dev-token-123"

These are available to the Rust code at compilation time and runtime. The downside is that every developer shares the same file, so it’s not suitable for personal API keys.

Common Mistakes to Avoid

Mistakes with environment variables often lead to silent failures — the app runs, but it uses the wrong endpoint or feature flag. Here are the patterns that trap newcomers.

Forgetting the VITE_ Prefix

You added API_URL=http://localhost:8080 to .env.development, but import.meta.env.API_URL is undefined. Vite only exposes variables starting with VITE_. Rename it to VITE_API_URL.

Expecting the Same Variable to Be Visible on Both Sides

Setting VITE_SECRET_KEY in .env does not make it available in Rust. The prefix VITE_ signals Vite to embed it in the frontend; Rust never sees it unless you also explicitly load it with dotenvy and use a separate variable name. The cleanest pattern is to keep frontend‑only values with the VITE_ prefix and backend‑only values without it, and never rely on one variable to serve both layers.

Leaking secrets to the frontend:

Any variable prefixed with VITE_ will be included in the final JavaScript bundle. Even if you never display it in the UI, someone can open the developer console and read it from the bundle source. Never store API keys, database credentials, or other secrets in a VITE_ variable.

Not Restarting After Changing .env

Vite caches environment variables at startup. If you modify .env.development while the dev server is running, your React components will still show the old values. Stop the process and run npm run tauri dev again.

Misplacing the .env File for Rust

When dotenvy::dotenv().ok() is called without a path, it looks for .env in the current working directory. During development that might be src-tauri. If your .env is at the project root, either use dotenvy::from_filename("../.env.development") or start the dev process from the root and adjust the working directory in tauri.conf.json. Many teams keep the .env inside src-tauri to avoid confusion.

Assuming TAURI_ENV_DEBUG Works in Rust

As covered earlier, TAURI_ENV_DEBUG is set only for hook commands. Reading it from Rust with std::env::var("TAURI_ENV_DEBUG") will return an error unless you manually exported it yourself. For a reliable dev‑only flag in Rust, define your own variable, such as DEV_MODE=true, and set it before running tauri dev.

Practical Development Workflow

A typical workflow for adding a new environment variable looks like this:

  1. Decide which layer needs the value — frontend, backend, or both.
  2. Add the variable to .env.development (and .env.production if it differs).
  3. Frontend only: prefix it with VITE_, then use import.meta.env.VITE_... in your React code. Restart the dev server.
  4. Backend only: pick a name without VITE_, load the file in main.rs with dotenvy, and call std::env::var inside your Tauri commands. Recompile with cargo tauri dev.
  5. Both sides: define two separate variables, one for the frontend (prefixed) and one for the backend (non‑prefixed). Share the same value in your .env file but never assume one variable is visible across the boundary.

Verification checklist:

  • Frontend: console.log(import.meta.env.VITE_MY_VAR) should print the expected string.
  • Backend: call a test Tauri command from the React app that returns the value, and display it temporarily.
  • Ensure your .env.development file is in .gitignore if it contains secrets. Commit a .env.example with dummy values instead.

Summary

Environment variables in Tauri development are a tool for keeping configuration separate from code, but they behave differently on each side of the architecture. The frontend receives build‑time‑embedded values through Vite’s VITE_ prefix, while the backend reads the real runtime environment, optionally loaded from a .env file via the dotenvy crate. Understanding this split prevents the most common errors: leaking secrets to the browser, forgetting the VITE_ prefix, and expecting variables to cross the webview boundary automatically.