How to Build a Local-First AI App with Tauri + Ollama

A complete guide to building a privacy-focused AI application that runs entirely on the user's device.

Why Local-First AI?

  • Privacy: No data leaves the device
  • Cost: Zero API costs at scale
  • Offline: Works without internet
  • Latency: Sub-second response times
  • Compliance: Meets strict data regulations

Architecture Overview

┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ Tauri Frontend │────▶│ Rust Backend │────▶│ Ollama API │ │ (React/Svelte) │ │ (Tauri Core) │ │ (Local LLM) │ └─────────────────┘ └─────────────────┘ └─────────────────┘ 

Prerequisites

# Install Tauri CLI cargo install tauri-cli # Install Ollama curl -fsSL https://ollama.ai/install.sh | sh # Pull a model ollama pull llama3.2:3b 

Project Setup

# Create Tauri app cargo create-tauri-app ai-chat --template react-ts cd ai-chat # Add dependencies cargo add reqwest --features json,blocking cargo add serde --features derive cargo add tauri-plugin-shell --features process 

Rust Backend: Ollama Integration

// src-tauri/src/ollama.rs use reqwest::blocking::Client; use serde::{Deserialize, Serialize}; use std::time::Duration; #[derive(Debug, Serialize, Deserialize)] pub struct ChatRequest { pub model: String, pub messages: Vec<Message>, pub stream: bool, #[serde(skip_serializing_if = "Option::is_none")] pub options: Option<ModelOptions>, } #[derive(Debug, Serialize, Deserialize)] pub struct Message { pub role: String, pub content: String, } #[derive(Debug, Serialize, Deserialize)] pub struct ModelOptions { pub temperature: Option<f32>, pub top_p: Option<f32>, pub num_predict: Option<i32>, } #[derive(Debug, Deserialize)] pub struct ChatResponse { pub message: Message, pub done: bool, } pub struct OllamaClient { client: Client, base_url: String, } impl OllamaClient { pub fn new(base_url: String) -> Self { Self { client: Client::builder() .timeout(Duration::from_secs(120)) .build() .unwrap(), base_url, } } pub fn chat(&self, request: ChatRequest) -> Result<ChatResponse, String> { let url = format!("{}/api/chat", self.base_url); let response = self.client .post(&url) .json(&request) .send() .map_err(|e| format!("Request failed: {}", e))?; if !response.status().is_success() { return Err(format!("API error: {}", response.status())); } response .json::<ChatResponse>() .map_err(|e| format!("Parse error: {}", e)) } pub fn list_models(&self) -> Result<Vec<String>, String> { let url = format!("{}/api/tags", self.base_url); let response = self.client.get(&url).send() .map_err(|e| format!("Request failed: {}", e))?; #[derive(Deserialize)] struct ModelsResponse { models: Vec<ModelInfo>, } #[derive(Deserialize)] struct ModelInfo { name: String, } response.json::<ModelsResponse>() .map(|r| r.models.into_iter().map(|m| m.name).collect()) .map_err(|e| format!("Parse error: {}", e)) } } 

Tauri Commands

// src-tauri/src/lib.rs use tauri::command; use crate::ollama::{OllamaClient, ChatRequest, Message}; #[command] fn chat(model: String, messages: Vec<Message>) -> Result<String, String> { let client = OllamaClient::new("http://localhost:11434".to_string()); let request = ChatRequest { model, messages, stream: false, options: Some(ModelOptions { temperature: Some(0.7), top_p: Some(0.9), num_predict: Some(512), }), }; let response = client.chat(request)?; Ok(response.message.content) } #[command] fn list_models() -> Result<Vec<String>, String> { let client = OllamaClient::new("http://localhost:11434".to_string()); client.list_models() } 

Frontend: React Chat Interface

// src/components/Chat.tsx import { useState, useEffect } from 'react'; import { invoke } from '@tauri-apps/api/core'; interface Message { role: 'user' | 'assistant'; content: string; } export function Chat() { const [messages, setMessages] = useState<Message[]>([]); const [input, setInput] = useState(''); const [model, setModel] = useState('llama3.2:3b'); const [models, setModels] = useState<string[]>([]); const [loading, setLoading] = useState(false); useEffect(() => { loadModels(); }, []); async function loadModels() { try { const available = await invoke<string[]>('list_models'); setModels(available); if (available.length > 0 && !available.includes(model)) { setModel(available[0]); } } catch (e) { console.error('Failed to load models:', e); } } async function handleSend() { if (!input.trim() || loading) return; const userMessage: Message = { role: 'user', content: input }; setMessages(prev => [...prev, userMessage]); setInput(''); setLoading(true); try { const response = await invoke<string>('chat', { model, messages: [...messages, userMessage].map(m => ({ role: m.role, content: m.content })) }); setMessages(prev => [...prev, { role: 'assistant', content: response }]); } catch (e) { setMessages(prev => [...prev, { role: 'assistant', content: `Error: ${e}` }]); } finally { setLoading(false); } } return ( <div className="chat-container"> <div className="model-selector"> <select value={model} onChange={e => setModel(e.target.value)}> {models.map(m => <option key={m} value={m}>{m}</option>)} </select> </div> <div className="messages"> {messages.map((msg, i) => ( <div key={i} className={`message ${msg.role}`}> <strong>{msg.role === 'user' ? 'You' : 'AI'}:</strong> <p>{msg.content}</p> </div> ))} </div> <div className="input-area"> <input value={input} onChange={e => setInput(e.target.value)} onKeyDown={e => e.key === 'Enter' && !e.shiftKey && (e.preventDefault(), handleSend())} placeholder="Type a message..." disabled={loading} /> <button onClick={handleSend} disabled={loading || !input.trim()}> {loading ? 'Thinking...' : 'Send'} </button> </div> </div> ); } 

Styling

/* src/components/Chat.css */ .chat-container { display: flex; flex-direction: column; height: 100vh; max-width: 800px; margin: 0 auto; padding: 1rem; } .model-selector { padding: 0.5rem; border-bottom: 1px solid #eee; } .model-selector select { padding: 0.5rem; border-radius: 4px; border: 1px solid #ddd; } .messages { flex: 1; overflow-y: auto; padding: 1rem 0; display: flex; flex-direction: column; gap: 1rem; } .message { max-width: 85%; padding: 0.75rem 1rem; border-radius: 12px; line-height: 1.5; } .message.user { align-self: flex-end; background: #3b82f6; color: white; border-bottom-right-radius: 4px; } .message.assistant { align-self: flex-start; background: #f3f4f6; color: #1f2937; border-bottom-left-radius: 4px; } .input-area { display: flex; gap: 0.5rem; padding-top: 1rem; border-top: 1px solid #eee; } .input-area input { flex: 1; padding: 0.75rem 1rem; border: 1px solid #ddd; border-radius: 8px; font-size: 1rem; } .input-area button { padding: 0.75rem 1.5rem; background: #3b82f6; color: white; border: none; border-radius: 8px; cursor: pointer; font-weight: 500; } .input-area button:disabled { opacity: 0.6; cursor: not-allowed; } 

Main App

// src/App.tsx import { Chat } from './components/Chat'; import './components/Chat.css'; function App() { return ( <div className="app"> <header> <h1>🦙 Local AI Chat</h1> <p>Powered by Tauri + Ollama — 100% Private</p> </header> <Chat /> </div> ); } export default App; 

Running the App

# Terminal 1: Start Ollama ollama serve # Terminal 2: Run Tauri dev cargo tauri dev 

Building for Distribution

# Build optimized release cargo tauri build # Output: src-tauri/target/release/bundle/ # - .msi (Windows) # - .dmg (macOS) # - .AppImage/.deb (Linux) 

Key Advantages

Feature Cloud API Local (This Approach)
Monthly Cost $50-5000+ $0
Privacy Data sent to 3rd party Never leaves device
Latency 200-2000ms 50-500ms
Availability Depends on provider Always (if device on)
Model Choice Limited to provider Any GGUF model

Next Steps

  1. Add streaming — Use stream: true and Server-Sent Events for real-time tokens
  2. Persistence — Save conversations to local SQLite via Tauri's SQL plugin
  3. Model management — UI to pull/delete models from Ollama library
  4. System prompt — Let users customize AI behavior
  5. Export/Import — Backup conversations as JSON

Resources

Built with Tauri 2.0, Rust 1.80+, and Ollama. Zero cloud dependencies.