Building a Native Procurement Workspace with Tauri, React, Rust and Offline-First Architecture
Most procurement software begins as a web application.
That is a sensible starting point. Browsers provide distribution, automatic updates, platform independence and a mature application environment.
But eventually a different engineering problem appears.
What happens when the application stops being primarily a place where users look up tenders and becomes a workspace where teams actually perform procurement work?
That distinction influenced the architecture of Tenders-SA Desktop, the native Windows procurement workspace built for the Tenders-SA platform.
The application is not simply the Tenders-SA website running inside a desktop window.
It is a separate Tauri 2 application, built around:
- Rust
- Tauri 2
- React 19
- TypeScript
- Vite
- SQLite
- TanStack Query
- Zustand
- Zod
- TipTap
- PDF.js
The web platform remains the system of record.
The desktop application provides a local operational environment around it.
That separation sounds simple, but it has significant consequences for authentication, synchronization, caching, security, offline behaviour and release engineering.
This article explains some of those decisions.
Why Build a Desktop Client at All?
A procurement workflow is more stateful than a typical search application.
A user may need to:
- discover a tender,
- inspect the tender documents,
- determine eligibility,
- evaluate compliance requirements,
- build an application plan,
- assign tasks,
- prepare response documents,
- work with company records,
- monitor closing dates,
- revisit the same tender repeatedly over several days.
A web application can support all of this.
But a native workspace provides another useful property: local operational continuity.
The desktop application's job is therefore not to replace the server.
Its job is to bring the server's procurement data into a workstation-oriented environment while maintaining a clearly defined authority boundary.
That led us to one of the most important architectural rules in the project:
The desktop client may cache and operate on platform data, but it must never become a competing source of truth.
The main Tenders-SA application remains authoritative for users, companies, tenders, documents, subscriptions, workspaces and other shared platform data.
Architecture at a High Level
The simplified architecture looks like this:
┌──────────────────────────┐
│ Tenders-SA │
│ Main Application │
│ │
│ Authoritative data │
└────────────┬─────────────┘
│
│ HTTPS / JSON
│
┌────────────▼─────────────┐
│ Typed API Transport │
│ │
│ Runtime validation │
│ Retry policy │
│ Error normalization │
│ Cancellation │
└────────────┬─────────────┘
│
┌───────────────────┴────────────────────┐
│ │
┌─────────▼─────────┐ ┌──────────▼─────────┐
│ React / TypeScript │ │ Native Rust Layer │
│ │ │ │
│ UI │◄──── Tauri IPC ─►│ Security │
│ Workspaces │ │ Keychain │
│ State │ │ Encryption │
│ Query cache │ │ SQLite │
└─────────┬──────────┘ └──────────┬─────────┘
│ │
└────────────────┬───────────────────────┘
│
┌────────▼─────────┐
│ Local Workspace │
│ │
│ SQLite cache │
│ Sync queue │
│ Preferences │
│ Local metadata │
└─────────────────┘
The important part of this diagram is not Tauri or React.
It is the authority boundary.
The desktop application deliberately avoids modelling itself as an independent procurement database.
Tauri Instead of Shipping Another Browser
Tauri was selected because it offers a useful architecture for this type of application.
The UI can still be developed using the React ecosystem, while sensitive functionality lives behind Rust commands and explicit Tauri capabilities.
Instead of shipping a complete Chromium runtime as part of the application, Tauri uses the operating system's WebView.
On Windows, that means WebView2.
This significantly changes the native application model compared with traditional Electron packaging.
But there is an interesting distribution trade-off.
We wanted installation to succeed even when WebView2 was unavailable and the machine had no internet connection.
The installer therefore embeds the WebView2 offline runtime.
That increases installer size considerably, but it produces an important property:
Installer downloaded
│
▼
No internet available
│
▼
Application can still install
For enterprise and government-related environments, where machines may operate behind restricted networks, predictable installation can be more valuable than minimizing every megabyte of the installer.
The project currently produces both:
NSIS -> .exe
MSI -> .msi
for 64-bit Windows.
SQLite Without Creating a Second Backend
Introducing SQLite into a desktop application creates an architectural temptation.
You can very quickly begin reproducing your server schema locally.
We explicitly avoided that.
The local database contains infrastructure such as:
cache_entries
recent_records
local_preferences
local_file_references
sync_operations
sync_conflicts
Notice what is deliberately absent.
There is no independent local:
tenders
companies
applications
suppliers
awards
users
schema acting as another domain database.
Instead, cached server objects can be stored as opaque payloads with metadata such as:
entity_type
entity_id
etag
expiry
payload
The distinction matters.
A cache entry says:
"This is a locally retained representation of an authoritative remote object."
A duplicated domain table can easily begin saying:
"This is the object."
Those are very different architectural contracts.
Repository Abstraction Around SQLite
The TypeScript layer does not directly depend on the Tauri SQL plugin everywhere.
Instead, database access is behind a small executor abstraction.
Conceptually:
interface SqlExecutor {
execute(
query: string,
params?: unknown[]
): Promise<unknown>;
select<T>(
query: string,
params?: unknown[]
): Promise<T[]>;
}
Repositories consume that interface.
The Tauri implementation talks to SQLite.
Tests can inject a fake implementation.
That produces several useful properties.
First, repository tests do not require a running desktop application.
Second, parameterisation can be verified directly.
For example, user or tender content should never become:
`SELECT * FROM cache WHERE title = '${title}'`
Values are bound separately.
Conceptually:
db.select(
"SELECT * FROM cache_entries WHERE entity_id = ?",
[entityId]
);
The fake SQL executor can assert that dangerous values appear only inside the parameter array and never inside the SQL string.
That turns "we use parameterized SQL" from a convention into something testable.
Offline-First Does Not Mean "Synchronize Everything"
Offline functionality becomes dangerous when it is reduced to:
failed request
↓
save request
↓
retry forever
Real synchronization needs explicit semantics.
Tenders-SA Desktop models queued mutations through a state machine.
The core flow is:
PENDING
│
▼
SYNCING
│
├────────► COMPLETE
│
├────────► CONFLICTED
│
└────────► FAILED
│
└────────► PENDING
retry
There is also a terminal CANCELLED state.
This is intentionally boring.
Boring state machines are good.
They make impossible transitions explicit.
For example:
COMPLETE -> SYNCING
is invalid.
So is repeatedly "resolving" a conflict that somebody has already manually resolved.
The synchronization layer distinguishes four broad outcomes:
success
transient
conflict
terminal
A transient network failure may be retried.
Invalid data should not.
A conflict should not quietly overwrite server state.
Conflict Resolution Must Be Explicit
Procurement data can be consequential.
Pricing, proposal content and application information should not silently disappear because two versions existed at the same time.
When a sync conflict occurs, the application records both versions.
Conceptually:
{
"localVersion": { "...": "..." },
"remoteVersion": { "...": "..." },
"resolutionState": "pending"
}
Nothing automatically decides:
local always wins
or:
server always wins
For sensitive entities such as proposal or pricing information, resolution can require explicit human action.
Choosing the local version can requeue the mutation.
Choosing the remote version can mark the local mutation as superseded.
The conflict record remains useful as an audit trail.
This is one of the areas where offline-first engineering becomes less about storage and more about business semantics.
Dependency-Aware Synchronization
Some offline operations depend on previous operations.
Imagine:
Create application
↓
Create application task
↓
Attach task document
The third operation cannot safely execute before the first.
The synchronization engine therefore supports dependency ordering and topologically sorts pending operations.
If operation C depends on B, and B depends on A, execution becomes:
A → B → C
rather than simply:
oldest timestamp first
There is another important rule.
If a dependency permanently fails, dependent operations do not automatically continue.
Doing so could create partial or logically impossible server state.
Dependency cycles are also treated as errors rather than allowing the sync worker to loop indefinitely.
Authentication Secrets Do Not Belong in SQLite
Another strict boundary is authentication storage.
Access and refresh tokens are not persisted inside the application database.
They are stored through the operating system's secure credential facilities.
The Rust security layer exposes a narrow secret-store abstraction backed by the OS keychain.
On Windows, that means the native credential manager.
The React application does not get an unrestricted credential database.
Instead, it calls deliberately restricted native commands.
Conceptually:
React
│
│ IPC command
▼
Rust
│
▼
OS Credential Store
The accepted session keys form a closed set rather than allowing arbitrary strings.
That makes the credential surface easier to audit.
AES-256-GCM for Sensitive Local Payloads
Not everything sensitive is an authentication token.
Some locally cached payloads may still contain information that should not be stored as plaintext.
For those cases the native layer implements authenticated encryption using AES-256-GCM.
The encryption key is generated locally and stored through the same secure secret-storage boundary.
Importantly:
encryption key
✕
does not enter WebView
Only ciphertext crosses the IPC boundary.
Each encryption operation uses a fresh nonce.
The resulting structure is effectively:
nonce || ciphertext || authentication tag
before encoding.
AES-GCM provides both confidentiality and tamper detection.
If encrypted data is modified, authentication fails rather than returning corrupted plaintext.
Least-Privilege Native Capabilities
One thing we wanted to avoid was treating Tauri's native capabilities as:
"Enable everything now because we might need it later."
Capabilities are added when a feature requires them.
The security boundary was built around a small number of explicitly exposed operations.
Filesystem, shell, URL opening and similar capabilities are not automatically granted globally.
This is especially relevant for desktop applications because a compromised webview becomes dramatically more dangerous when it has broad native permissions.
The design principle is therefore:
Feature requires capability
↓
Audit exact requirement
↓
Grant narrow permission
rather than:
Enable native capability
↓
Maybe use it someday
Content Security Policy Still Matters in Desktop Applications
Desktop webviews are still web execution environments.
A strict Content Security Policy therefore remains useful.
Remote script execution is prohibited.
Object embedding is disabled.
Connection behaviour is constrained.
The larger lesson is that adopting Tauri does not remove web security concerns.
It creates two security boundaries that need to be considered together:
Web security boundary
+
Native capability boundary
A secure desktop application must care about both.
The API Contract Is Runtime-Validated
TypeScript provides excellent compile-time safety.
But TypeScript types disappear at runtime.
An API can therefore return:
{
"something": "completely unexpected"
}
and your TypeScript interface cannot prevent it.
The desktop transport uses runtime schema validation to address that boundary.
With Zod-style validation the flow becomes:
HTTP response
↓
JSON parsing
↓
Runtime schema validation
↓
Typed application object
A malformed 2xx response is not treated as valid simply because the HTTP status was successful.
It becomes a transport-level malformed error.
This is particularly useful when a desktop application and its parent web platform evolve independently.
Errors Are Modelled as Data
Another important transport choice was normalising failure modes.
Instead of every feature interpreting raw fetch exceptions, the transport exposes a closed error vocabulary such as:
unauthorized
forbidden
not-found
rate-limited
validation
server
offline
timeout
cancelled
malformed
This is extremely useful elsewhere.
The synchronization engine does not need to understand HTTP.
It only needs to know whether an error maps to:
retry
fail
conflict
stop
Separating transport classification from synchronization policy reduces duplicated retry logic.
Retries Need Policy
"Retry failed requests" sounds useful until the failed request is a mutation.
Replaying arbitrary operations can create duplicates.
The transport therefore distinguishes safe retry behaviour explicitly.
Conceptually:
retry: "safe-idempotent" | "never"
Temporary conditions such as:
offline
timeout
rate limiting
server error
may be retried where appropriate.
Authentication failures and validation failures should not.
Backoff is bounded and exponential instead of creating an uncontrolled request loop.
The same philosophy exists inside the offline mutation queue.
The Desktop Client Uses the Main Application as Its Backend
Another architectural decision worth mentioning is that the desktop client is not designed around a completely separate desktop backend.
It talks to the existing Tenders-SA application contract.
That matters because the desktop application needs more than public tender discovery.
It needs application-level capabilities such as:
authentication
company profile
subscriptions
application workspaces
documents
tasks
notifications
mutations
A public read API is not sufficient for that.
The desktop application therefore behaves as another first-party Tenders-SA client.
The browser application and desktop application are different clients over the same authoritative platform.
A Procurement Workspace, Not Just Tender Search
The architecture becomes more understandable when looking at what the current application actually exposes.
Implemented areas include:
- Command Centre
- Tender Radar
- Tender search
- Tender detail
- Saved opportunities
- Application Workspaces
- Calendar
- Tasks
- Company Profile
- Document Vault
- Supplier Intelligence
- Procurement Officers
- Notifications
- Settings
The Application Workspace follows a procurement-oriented flow:
Understand
↓
Plan
↓
Qualify
↓
Draft
↓
Review
The response-document environment includes WYSIWYG editing, Markdown support, AI instruction composition, version history and offline saving.
This is where the desktop architecture starts making sense.
The application is intended to become the place where procurement work happens, not merely another view of a tender listing.
The Server Still Wins
Offline-first applications sometimes drift into a dangerous assumption:
If we have SQLite locally, we can operate independently indefinitely.
That is explicitly not the model here.
Tenders-SA Desktop is a local-first workspace around a server-authoritative platform.
That distinction gives us both:
local responsiveness
offline continuity
and:
centralized authority
cross-device consistency
shared organisational state
The difficult engineering is in maintaining the boundary between those two systems.
Signed Updates Are Part of the Security Model
Desktop distribution adds another responsibility web developers normally do not have:
You ship executable code to user machines.
Updating that executable therefore becomes part of the security architecture.
Tenders-SA Desktop uses Tauri's signed updater.
The public verification key is included in the application.
The corresponding private key is retained only in repository secrets used by the controlled release workflow.
The client verifies downloaded releases before installation.
Conceptually:
Release artifact
│
▼
Cryptographic signature
│
▼
Client public key verification
│
├── valid ───► update available
│
└── invalid ─► reject
There is deliberately no unsigned production-update mode.
Release Builds Are a Deliberate Gate
Normal CI runs quality checks such as:
formatting
lint
TypeScript checks
tests
Rust checks
But Windows packaging is not performed automatically on every push.
Packaging is a manually initiated GitHub Actions workflow.
That gives the repository a deliberate release boundary between:
code that passed CI
and:
binary we intend users to install
The production release workflow generates the Windows installers and signed updater metadata that installed clients consume.
Native Software Changes Your Engineering Responsibilities
Moving from web-only software into native desktop software introduces concerns that are easy to underestimate.
You suddenly own:
local migrations
OS credential storage
native permissions
offline synchronization
conflict resolution
installer behaviour
runtime availability
binary signing
automatic updates
desktop observability
The interesting part of building Tenders-SA Desktop was therefore not putting React inside Tauri.
That part is relatively straightforward.
The difficult part was defining exactly where local state ends and platform authority begins.
What We Learned
Several principles from the project are broadly applicable.
1. Offline-first is primarily a synchronization problem
SQLite is the easy part.
Conflict semantics, dependency ordering and retry safety are the difficult parts.
2. Never casually create two sources of truth
If the server owns an entity, your local database should clearly communicate whether it is caching, staging or mirroring that entity.
3. Native capabilities should be granted incrementally
A desktop webview should not automatically inherit unrestricted filesystem, shell or network access.
4. TypeScript is not runtime validation
Validate server responses at the network boundary.
5. Authentication storage deserves its own architecture
Tokens should not simply land in whatever persistence mechanism was easiest to implement.
6. Updates are a supply-chain concern
If your software can update itself, cryptographic verification is part of application security.
7. Error classification simplifies everything above it
When transport errors have predictable semantics, synchronization and UI logic become substantially easier to reason about.
Where the Project Is Going
Not every desktop feature has been implemented.
Areas such as deeper proposal functionality, JV and partner networking, buyer intelligence, award intelligence and reporting are intentionally exposed as unavailable rather than represented by dead navigation.
That is deliberate.
A desktop workspace should grow alongside real backend contracts rather than accumulating placeholder architecture.
The long-term direction is a procurement operations environment in which suppliers can move from:
Opportunity discovery
↓
Qualification
↓
Planning
↓
Document preparation
↓
Application management
↓
Submission readiness
inside one coherent workspace while the Tenders-SA platform remains the authoritative data system underneath it.
Source Code
The desktop application is developed in the public repository:
https://github.com/Tenders-SA/tenders-sa-desktop
The repository includes architecture decision records covering:
docs/architecture/api.md
docs/architecture/auth.md
docs/architecture/local-data.md
docs/architecture/security.md
docs/architecture/sync.md
docs/architecture/observability.md
as well as development, specification and release documentation.
The Windows application can be downloaded from:
Procurement Operating Systems - Tenders SA Desktop
Closing
There is a common misconception that desktop development with modern web technology means wrapping a website in a native window.
It can.
But that is not particularly interesting.
The more interesting architecture appears when the desktop application becomes a real participant in the system:
native security
+
local persistence
+
offline operations
+
server authority
+
runtime validation
+
controlled synchronization
+
signed distribution
That is the direction we have taken with Tenders-SA Desktop.
The result is not a replacement for the Tenders-SA web platform.
It is a native operational layer built around it.
And from an engineering perspective, that distinction changes almost everything.
Suggested DEV Community tags: #tauri #rust #react #typescript
Top comments (0)