Back to Blog

Tauri HTTP Requests Tutorial - Build a Quote of the Day App | React + Rust

Sandy LaneSandy Lane

Video: Tauri HTTP Requests Tutorial - Build a Quote of the Day App | React + Rust by Taught by Celeste AI - AI Coding Coach

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

Two options: tauri-plugin-http for direct fetches from the frontend, or reqwest from Rust commands. Which one to pick depends on what your app needs to do.

Web apps use fetch for HTTP. Tauri apps can too — but they have a better option: making the request from Rust. This sidesteps CORS, lets you add custom headers and authentication securely, and keeps secrets off the frontend.

This lesson covers both options and builds a small "Quote of the Day" app to make it concrete.

Option A: HTTP from the frontend with the http plugin

cargo add tauri-plugin-http
npm install @tauri-apps/plugin-http

Register:

.plugin(tauri_plugin_http::init())

Capability:

"permissions": [
  "core:default",
  {
    "identifier": "http:default",
    "allow": [{ "url": "https://api.quotable.io/*" }]
  }
]

The http:default permission requires you to whitelist the URLs your app can hit. Without the whitelist, requests are blocked. Tauri's security model defaults to deny.

Frontend:

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

const response = await fetch("https://api.quotable.io/random");
const data = await response.json();
console.log(data.content);    // the quote

Same fetch API as the browser, but the request goes through Tauri's HTTP stack — no CORS, no browser-imposed restrictions, full control.

Option B: HTTP from Rust with reqwest

# Cargo.toml
[dependencies]
reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1", features = ["derive"] }
use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize)]
struct Quote {
  content: String,
  author: String,
}

#[tauri::command]
async fn fetch_quote() -> Result<Quote, String> {
  let resp = reqwest::get("https://api.quotable.io/random")
    .await
    .map_err(|e| e.to_string())?;
  let quote = resp.json::<Quote>().await.map_err(|e| e.to_string())?;
  Ok(quote)
}

Frontend:

const quote = await invoke<{ content: string; author: string }>("fetch_quote");

The Rust side handles the network entirely. The frontend just calls invoke.

When to pick which

Frontend (http plugin): - Quick UI prototyping. - Public APIs without authentication. - When you want browser-like fetch ergonomics.

Rust (reqwest): - Authentication: API keys, OAuth, signed requests. Keep credentials out of the frontend. - Complex transformations: parse, filter, cache before returning. - Streaming: large responses, partial reads. - When the same logic is used by other Rust commands.

For most production apps, the Rust approach wins. The frontend stays focused on UI; the network layer stays in Rust where it can be tested, mocked, and secured.

The Quote of the Day app

State:

const [quote, setQuote] = useState<{ content: string; author: string } | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);

Fetch:

async function fetchQuote() {
  setLoading(true);
  setError(null);
  try {
    const data = await invoke<{ content: string; author: string }>("fetch_quote");
    setQuote(data);
  } catch (e) {
    setError(String(e));
  } finally {
    setLoading(false);
  }
}

useEffect(() => {
  fetchQuote();
}, []);

UI:

return (
  <main className="h-screen flex flex-col items-center justify-center p-8">
    {loading && <p>Loading...</p>}
    {error && <p className="text-red-500">{error}</p>}
    {quote && (
      <>
        <blockquote className="text-2xl italic">"{quote.content}"</blockquote>
        <p className="mt-4 text-slate-500">— {quote.author}</p>
      </>
    )}
    <button onClick={fetchQuote} className="mt-8 px-4 py-2 bg-blue-500 text-white rounded">
      New quote
    </button>
  </main>
);

Three states (loading, error, quote) drive the rendering. Standard React data-fetching pattern.

Authentication

For an API that requires a key:

#[tauri::command]
async fn fetch_with_auth() -> Result<String, String> {
  let key = std::env::var("API_KEY").map_err(|_| "API_KEY not set")?;
  let client = reqwest::Client::new();
  let resp = client
    .get("https://api.example.com/data")
    .bearer_auth(key)
    .send()
    .await
    .map_err(|e| e.to_string())?;
  resp.text().await.map_err(|e| e.to_string())
}

The API key lives in an environment variable on the user's machine — or in a config file the app reads. The frontend never sees it. Even if a malicious script ran inside the webview, it couldn't steal the key.

For per-user OAuth tokens, store them via Tauri's Storage or a secure-keystore plugin like tauri-plugin-stronghold.

Error handling

A real fetch can fail in many ways: network down, server 500, timeout, malformed JSON. Surface each clearly:

#[derive(Serialize)]
#[serde(tag = "kind", content = "message")]
enum FetchError {
  Network(String),
  Server { status: u16, body: String },
  Parse(String),
}

#[tauri::command]
async fn fetch_quote() -> Result<Quote, FetchError> {
  let resp = reqwest::get("https://api.quotable.io/random")
    .await
    .map_err(|e| FetchError::Network(e.to_string()))?;
  if !resp.status().is_success() {
    let status = resp.status().as_u16();
    let body = resp.text().await.unwrap_or_default();
    return Err(FetchError::Server { status, body });
  }
  resp.json::<Quote>().await.map_err(|e| FetchError::Parse(e.to_string()))
}

Frontend:

try {
  const data = await invoke<Quote>("fetch_quote");
} catch (err) {
  if (err.kind === "Server") setError(`Server error ${err.status}`);
  else if (err.kind === "Network") setError("Check your connection");
  else setError("Couldn't read the response");
}

Structured errors are more informative than strings — both for users and for telemetry.

Caching

For an API that doesn't change often, cache responses on disk. The pattern:

  1. On fetch, check the cache file's age.
  2. If younger than the TTL, return the cached value.
  3. Otherwise, hit the network and write the result back.

Keeps your app responsive offline and reduces API quota usage.

Common mistakes

Hardcoding API keys in JavaScript. Visible in the bundle. Use Rust commands.

No timeout on requests. A hung server hangs your app. reqwest::Client::builder().timeout(Duration::from_secs(10)).

Not whitelisting URLs in the http plugin. Requests fail with ACL errors. Add the host to http:default allow list.

Parsing JSON optimistically. resp.json() panics if the body isn't valid JSON. Use ? and surface parse errors.

Forgetting CORS isn't a thing here. Frontend devs reach for CORS workarounds; Tauri doesn't need them. The Rust HTTP stack ignores CORS.

What's next

Next lesson: SQLite database — Contacts Manager. Persist data to a real database from a Tauri app.

Recap

Two paths: tauri-plugin-http's fetch from the frontend, or reqwest from a Rust command. Rust wins for auth, caching, transformation, and security. Whitelist URLs in the http plugin's capability. Use structured error types for richer error UI.

Next: SQLite contacts manager.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.