Back to Blog
Tauri React SQLite Desktop Apps

Building a Lead Management System in a Weekend

How we built a desktop lead management tool from scratch using Tauri, React, and SQLite in a weekend — with bulk operations, data enrichment, and exclusion filtering.

Building a Lead Management System in a Weekend

We needed a lead management system for a specific use case: discovering, qualifying, and tracking outreach for leads from a public data source. The off-the-shelf options were either too generic (a CRM with 200 features we would not use) or too expensive for what amounted to a focused workflow tool.

So we built one in a weekend. A desktop app that imports leads, enriches them against an external API, filters out unwanted contacts, tracks outreach status, and exports qualified leads to CSV. Here is how we approached it and what made it possible to ship that fast.

The Stack

  • Tauri v2 — desktop framework with Rust backend
  • React 19 with TypeScript — frontend
  • TanStack Table — data grid with sorting, filtering, and row selection
  • SQLite via SQLx — async database layer in Rust
  • Tailwind CSS v4 — styling
  • Sonner — toast notifications

We chose Tauri over a web app because the workflow involves processing thousands of records locally, calling rate-limited APIs, and working with data that should not leave the machine. A desktop app eliminates the need for a server, authentication, and hosting — all overhead that was not justified for an internal tool.

Data Model

Five tables, five migrations, zero over-engineering:

-- Core lead data
CREATE TABLE cases (
  serial_number TEXT PRIMARY KEY,
  mark_text TEXT,
  filing_date TEXT,
  status_code TEXT,
  applicant_name TEXT,
  is_candidate BOOLEAN DEFAULT 0,
  enriched_at TEXT
);

-- Contact information
CREATE TABLE representatives (
  serial_number TEXT,
  name TEXT,
  country TEXT,
  address TEXT,
  FOREIGN KEY (serial_number) REFERENCES cases(serial_number)
);

-- Workflow status
CREATE TABLE tracking (
  serial_number TEXT,
  review_status TEXT DEFAULT 'new',
  outreach_status TEXT DEFAULT 'none',
  notes TEXT,
  FOREIGN KEY (serial_number) REFERENCES cases(serial_number)
);

-- Exclusion lists
CREATE TABLE excluded_representatives (
  name TEXT UNIQUE COLLATE NOCASE,
  reason TEXT,
  excluded_at TEXT DEFAULT (datetime('now'))
);

CREATE TABLE excluded_applicants (
  name TEXT UNIQUE COLLATE NOCASE,
  reason TEXT,
  excluded_at TEXT DEFAULT (datetime('now'))
);

The COLLATE NOCASE on exclusion tables is important — you do not want “Smith & Associates” and “smith & associates” treated as different entries when filtering.

Status fields use simple text values (new, reviewing, qualified, disqualified for review; none, drafted, sent, responded for outreach) rather than numeric codes. This makes the database human-readable when debugging and eliminates the need for a lookup table.

Bulk Operations

The feature that makes this tool practical is bulk operations. When you are working with hundreds of leads, updating them one at a time is not viable.

The frontend uses TanStack Table’s row selection to let you check multiple rows, then apply a status change to all of them:

const handleBulkUpdate = async (field: string, value: string) => {
  const serials = selectedRows.map(r => r.serial_number);
  const count = await invoke('bulk_update_tracking', {
    serialNumbers: serials,
    field,
    value,
  });
  toast.success(`Updated ${count} candidate${count !== 1 ? 's' : ''}.`);
};

The Rust backend executes this as a single SQL statement with a parameterized IN clause, so updating 500 records takes milliseconds.

Data Enrichment Pipeline

Raw leads from public data sources often have incomplete information. Our enrichment pipeline fills in the gaps:

  1. Find all records where enriched_at is NULL
  2. Queue them for processing against the external API
  3. Rate-limit requests (we use the governor crate for token-bucket rate limiting)
  4. Update the record with enriched data and set enriched_at timestamp
  5. Stream progress events to the frontend in real-time via Tauri events

The rate limiting was essential. Without it, we would hit API limits within seconds and get blocked. The governor crate provides a clean token-bucket implementation:

use governor::{Quota, RateLimiter};
use std::num::NonZeroU32;

let limiter = RateLimiter::direct(
    Quota::per_second(NonZeroU32::new(2).unwrap())
);

for serial in serials {
    limiter.until_ready().await;
    // Make API call...
}

Exclusion Lists

Over time, you learn that certain contacts or companies are not worth pursuing. Rather than deleting these leads (which would cause them to reappear on the next import), we maintain exclusion lists.

When new leads are imported, they are automatically filtered against the exclusion lists. Excluded entries are hidden from the default view but can be shown with a toggle — you never lose data, you just filter it.

The exclusion UI supports:

  • Exclude with an optional reason (“competitor”, “no longer in business”, etc.)
  • Un-exclude to restore a contact
  • Case-insensitive matching

Real-Time Progress

Long operations (bulk discovery, enrichment) stream progress to the frontend via Tauri’s event system:

// Rust backend
app.emit("discovery-progress", ProgressPayload {
    processed: count,
    total: total,
    current_item: serial.clone(),
})?;
// React frontend
listen('discovery-progress', (event) => {
  setProgress(event.payload);
});

This gives users immediate feedback during operations that might take minutes. A progress bar beats a spinner every time.

What Made It Possible in a Weekend

Several factors made this timeline realistic:

Tauri’s IPC model is fast to develop with. Define a Rust function with #[tauri::command], call it from JavaScript with invoke(). No REST API design, no HTTP serialization, no CORS. The function call is type-safe on both sides.

SQLite eliminates infrastructure. No database server to set up, no connection pooling to configure, no migrations to run against a remote database. The database is a file. During development, resetting state means deleting a file.

TanStack Table handles the hard UI problems. Sorting, filtering, pagination, row selection, and column resizing are all built in. Building a data grid from scratch would have consumed the entire weekend by itself.

Tailwind v4 removes the styling bottleneck. No context-switching to CSS files, no class naming decisions. Style directly in the markup and move on.

Sonner for feedback. Toast notifications are a small thing, but they make the app feel responsive. One import and you have success/error feedback everywhere.

The Result

The app went from zero to usable in a weekend and has been refined incrementally since. Total codebase: about 750 lines of Rust for the backend, 1500 lines of React/TypeScript for the frontend. It handles thousands of leads, processes them against rate-limited APIs, and exports qualified leads for outreach.

The key takeaway is that Tauri + React + SQLite is an incredibly productive stack for internal tools. You get the developer experience of web technologies with the performance and privacy of a native app. No server costs, no authentication layer, no deployment pipeline — just build, install, and use.


KeyQ builds custom tools and internal applications that solve specific business problems without the overhead of enterprise software. Contact us if you need something built fast and built right.