External APIs

December 18, 2025 · View on GitHub

Patterns for calling external HTTP APIs from Tauri applications.

Note: HTTP client dependencies are not installed in this app. Install reqwest (Rust) and optionally tauri-plugin-keyring (for token storage) when your app needs external API calls.

Rust vs Frontend: When to Use Which

Default recommendation: Use Rust backend (reqwest)

ApproachProsCons
Rust (reqwest)CORS bypass, secure token storage, type safetyMore code per endpoint
Frontend (fetch)Less boilerplate, familiar APICORS restrictions, exposed keys

Use Rust Backend For

  • All authenticated API calls (keeps tokens out of WebView)
  • APIs with CORS restrictions (desktop apps bypass CORS from Rust)
  • Calls requiring response caching to local storage
  • Production applications

Use Frontend Fetch For

  • Public APIs with no authentication
  • Rapid prototyping before moving to Rust
  • Third-party SDKs requiring browser context

Setup

# Rust HTTP client
cd src-tauri && cargo add reqwest --features json,rustls-tls

For secure token storage, see the Authentication section below.

Architecture Pattern

Follow the same pattern as local data: Tauri commands wrap API calls, TanStack Query provides caching.

React Component → TanStack Query → Tauri Command (reqwest) → External API

Rust Command

use reqwest;
use serde::{Deserialize, Serialize};
use specta::Type;

#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct User {
    pub id: u32,
    pub name: String,
    pub email: String,
}

#[tauri::command]
#[specta::specta]
pub async fn fetch_user(user_id: u32) -> Result<User, String> {
    let client = reqwest::Client::new();

    let response = client
        .get(format!("https://api.example.com/users/{user_id}"))
        .timeout(std::time::Duration::from_secs(10))
        .send()
        .await
        .map_err(|e| format!("Network error: {e}"))?;

    if !response.status().is_success() {
        return Err(format!("API error: {}", response.status()));
    }

    response.json::<User>()
        .await
        .map_err(|e| format!("Parse error: {e}"))
}

React Service

// src/services/users.ts
export const userQueryKeys = {
  all: ['users'] as const,
  user: (id: number) => [...userQueryKeys.all, id] as const,
}

export function useUser(userId: number) {
  return useQuery({
    queryKey: userQueryKeys.user(userId),
    queryFn: async () => unwrapResult(await commands.fetchUser(userId)),
    staleTime: 1000 * 60 * 5, // Cache for 5 minutes
  })
}

export function useUpdateUser() {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: async ({
      userId,
      data,
    }: {
      userId: number
      data: Partial<User>
    }) => {
      const result = await commands.updateUser(userId, data)
      if (result.status === 'error') throw new Error(result.error)
      return result.data
    },
    onSuccess: (_, { userId }) => {
      queryClient.invalidateQueries({ queryKey: userQueryKeys.user(userId) })
    },
  })
}

Authentication

Token Storage Options

OptionSecurityUse When
keyring crateHigh (OS keychain)API tokens, credentials
tauri-plugin-strongholdHigh (encrypted DB)Multiple secrets, encryption keys
tauri-plugin-storeLow (plain JSON)Non-sensitive data only

For OS keychain access, use the keyring crate directly:

cd src-tauri && cargo add keyring
use keyring::Entry;

#[tauri::command]
#[specta::specta]
pub fn save_auth_token(token: String) -> Result<(), String> {
    let entry = Entry::new("myapp", "auth_token")
        .map_err(|e| format!("Keyring error: {e}"))?;
    entry.set_password(&token)
        .map_err(|e| format!("Failed to save token: {e}"))
}

#[tauri::command]
#[specta::specta]
pub fn get_auth_token() -> Result<Option<String>, String> {
    let entry = Entry::new("myapp", "auth_token")
        .map_err(|e| format!("Keyring error: {e}"))?;
    match entry.get_password() {
        Ok(token) => Ok(Some(token)),
        Err(keyring::Error::NoEntry) => Ok(None),
        Err(e) => Err(format!("Failed to get token: {e}")),
    }
}

Authenticated Requests

#[tauri::command]
#[specta::specta]
pub async fn fetch_protected_data() -> Result<Data, String> {
    let entry = Entry::new("myapp", "auth_token")
        .map_err(|e| format!("Keyring error: {e}"))?;
    let token = entry.get_password()
        .map_err(|_| "Not authenticated")?;

    let client = reqwest::Client::new();
    client
        .get("https://api.example.com/protected")
        .header("Authorization", format!("Bearer {token}"))
        .send()
        .await
        .map_err(|e| format!("Request failed: {e}"))?
        .json::<Data>()
        .await
        .map_err(|e| format!("Parse error: {e}"))
}

Error Handling

See error-handling.md for complete patterns. Key points for API calls:

// Configure retry for network errors, not validation errors
const { data } = useQuery({
  queryKey: ['api-data'],
  queryFn: fetchData,
  retry: (failureCount, error) => {
    if (error.message.includes('validation')) return false
    return failureCount < 3
  },
})

Offline Handling

For apps that need to work offline, cache API responses to SQLite:

#[tauri::command]
#[specta::specta]
pub async fn fetch_with_cache(app: tauri::AppHandle, id: u32) -> Result<Data, String> {
    // Try network first
    match fetch_from_api(id).await {
        Ok(data) => {
            cache_to_db(&app, &data)?;  // Cache for offline
            Ok(data)
        }
        Err(_) => {
            // Fallback to cache on network error
            load_from_cache(&app, id)
        }
    }
}

See data-persistence.md for SQLite setup.

Quick Reference

TaskPattern
Basic API callRust command with reqwest
CachingTanStack Query (frontend) or SQLite
Token storagekeyring crate (OS keychain)
Type safetytauri-specta (same as local commands)
Error handlingResult types, see error-handling.md
Offline supportCache to SQLite, fallback on network err