Tauri App: One Frontend Codebase for Native and Web
Keeping one frontend codebase for Tauri and the web sounds tidy until runtime differences leak into every feature. This post uses plugin-store as the example, but the same IPC mocking pattern also works for other plugins and selected commands.
Most cross-platform frontend codebases do not stay single-codebase for long. Platform checks spread, storage splits in two, and eventually the web app and the native app are only superficially related.
I wanted the opposite: one frontend that can run in two very different environments:
- as a Tauri app on desktop and mobile, where the browser runtime can call Rust commands and Tauri plugins
- as a plain web app, where there is no Rust process and no native Tauri runtime
The goal is to keep the same screens, business logic, and storage-facing API, while only swapping the platform behavior underneath.
That also means being honest about persistence. In native builds, I still use @tauri-apps/plugin-store because I want filesystem-backed storage there. On the web, the job is to provide a browser fallback that keeps the same contract and remains useful.
This post shows the pattern I use to keep that split contained.
I use @tauri-apps/plugin-store as the concrete example because my app depends on it heavily, but the mechanism is broader than that. The same mock-and-intercept approach can be applied to other Tauri plugins and selected invoke() commands, as long as you keep the contract clear.
In short:
- The app always talks to Tauri APIs from the frontend.
- In native Tauri builds, those calls go to Rust and Tauri plugins normally.
- In the web build, I enable Tauri's builtin IPC mocking and intercept the same calls in JavaScript.
- The mock implementation reproduces just enough plugin behavior for the web app to keep working.
That keeps most of the application largely unaware of whether it is running inside Tauri or inside a normal browser.
Architecture at a Glance
If you prefer to orient yourself with a diagram first, this is the overall shape of the setup:
flowchart TD
A["Shared React / Next frontend"] --> B["TauriProvider"]
B --> C{"Real Tauri runtime available?"}
C -->|Yes| D["Use normal Tauri IPC"]
D --> E["@tauri-apps/plugin-store"]
E --> F["Rust + Tauri plugin backend"]
C -->|No| G["Install mockIPC(ipcCallback)"]
G --> E
E --> H["JavaScript IPC mock"]
H --> I["IndexedDB via idb-keyval"]
B --> J["TauriContext"]
J --> K["Feature modules use store and isMocked"]
Why This Pattern Is Useful
If you build both a native app and a web app, the easy way is often to fork logic early:
- one storage layer for Tauri
- another storage layer for the browser
- different feature flags scattered through the UI
- lots of
if (isWeb)andif (isDesktop)checks
That works at first, but it pushes platform concerns into the entire app. The code becomes harder to reason about because every feature starts caring about where it is running.
I prefer the opposite direction:
- keep platform branching at the boundary
- expose one frontend API to the app
- make the fallback implementation behave like the native one as closely as practical
In my case, the most important boundary happens to be the Tauri store plugin. A lot of application state depends on it, so it makes a good example. But the larger pattern is not really about storage. It is about choosing a plugin boundary the frontend already trusts, then preserving that contract across native and web runtimes.
The Core Idea
The whole setup works because Tauri exposes a mock API for IPC calls. In a real Tauri runtime, frontend calls go through the native bridge. In a plain browser, I can install a mock handler and intercept those calls in JavaScript instead.
That gives me a clean split:
- native environment: use real Tauri behavior
- web environment: emulate the subset of Tauri behavior my app depends on
For the plugin-store version of this setup, the implementation revolves around two files:
providers/tauri-provider.tsx: decides whether to install mocks and provides the loaded store to the appproviders/tauri-mock.ts: implements the mocked IPC behavior for the store plugin example used in this post
That is the conceptual split. In practice, the request flow looks like this:
sequenceDiagram
participant UI as Feature code
participant Provider as TauriProvider
participant Store as plugin-store client
participant Native as Native Tauri runtime
participant Mock as ipcCallback mock
participant IDB as IndexedDB
UI->>Provider: read store from context
Provider->>Store: load('store.json')
alt Native desktop/mobile app
Store->>Native: plugin:store|get/set/keys/delete
Native-->>Store: real plugin response
else Plain web app
Provider->>Mock: mockIPC(ipcCallback)
Store->>Mock: plugin:store|get/set/keys/delete
Mock->>IDB: get / set / keys / del
IDB-->>Mock: browser persistence result
Mock-->>Store: plugin-compatible response
end
Store-->>UI: Store instance behaves the same
The Provider: Real Tauri on Native, Mocked Tauri on Web
The provider is where that runtime choice gets made:
/* eslint-disable no-console */
'use client';
import { load, Store } from '@tauri-apps/plugin-store';
import { useEffect, useState } from 'react';
import { clearMocks, mockIPC } from '@tauri-apps/api/mocks';
import { TauriContext } from '@/contexts/tauri-context';
import { ipcCallback } from './tauri-mock';
export default function TauriProvider({
children,
}: {
children: React.ReactNode;
}) {
const [store, setStore] = useState<Store | null>(null);
const [isMocked, setIsMocked] = useState(false);
useEffect(() => {
const initializeStore = async () => {
if (
process.env.NEXT_PUBLIC_TAURI_MOCKED ||
!window ||
!('__TAURI__' in window)
) {
console.log('Setting up Tauri IPC mocks');
setIsMocked(true);
mockIPC(ipcCallback);
}
const loadedStore = await load('store.json', {
defaults: {},
autoSave: false,
});
setStore(loadedStore);
};
initializeStore();
return () => {
clearMocks();
console.log('Cleared Tauri IPC mocks');
};
}, []);
const tauriContextValue = {
store,
isMocked,
};
return (
<TauriContext.Provider value={tauriContextValue}>
{children}
</TauriContext.Provider>
);
}This file does not do much, but the few things it does are important.
1. The App Always Loads the Same Store API
The provider always calls:
const loadedStore = await load('store.json', {
defaults: {},
autoSave: false,
});This matters because the rest of the app does not need a separate abstraction such as loadWebStore() versus loadTauriStore().
It just consumes a Store instance from context.
In other words, the application code does not ask:
- am I in a browser?
- am I in Tauri?
- should I use IndexedDB here?
It simply asks for the store.
That is the main design win in this example, and the same principle carries over to other plugins: keep the frontend-facing contract stable, and move the platform differences behind it.
2. Environment Detection Happens Once
This block decides whether to install the mock handler:
if (
process.env.NEXT_PUBLIC_TAURI_MOCKED ||
!window ||
!('__TAURI__' in window)
) {
setIsMocked(true);
mockIPC(ipcCallback);
}Conceptually, the rule is:
- if a real Tauri runtime is available, do nothing and let Tauri handle IPC normally
- if not, install a JavaScript IPC interceptor
The optional environment flag is useful because it gives me a manual override. Even if I am inside a Tauri-capable environment, I can still force the mocked path for testing or debugging.
This is especially helpful when I want to validate browser-compatible behavior without changing feature code.
This is also the reason I like to make the environment decision in one place only. Once that choice is made, everything downstream can stay boring.
3. The Mock Is Installed Before Store Usage
The order is important.
First I register the IPC mock:
mockIPC(ipcCallback);Then I call load() from @tauri-apps/plugin-store.
That means when the plugin-store package issues its internal IPC calls, they already have somewhere to go. In native Tauri, they go through the real bridge. In the browser, they are intercepted by ipcCallback.
The calling code is the same in both cases.
4. The Provider Exposes Two Things Only
The context value is intentionally small:
const tauriContextValue = {
store,
isMocked,
};That is enough for the app to:
- use the shared store API
- optionally adjust a few UI affordances when native-only features are unavailable
That second part is important. Some actions genuinely only make sense in native mode. For example, opening a store file location on disk is a native concern. The app can hide or disable that action when isMocked is true, while keeping the rest of the feature working.
The Mock: Reimplementing the Store Plugin Boundary in the Browser
Once the provider installs mocked IPC, this handler becomes the browser-side implementation:
import { get, set, del, keys } from 'idb-keyval';
const STORE_PREFIX = process.env.NEXT_PUBLIC_STORE_MOCK_PREFIX || 'store::';
const storeKey = (key: string) => STORE_PREFIX + key;
/* eslint-disable no-console */
export const ipcCallback = async (cmd: string, payload: any) => {
console.log(`Mocked IPC call: ${cmd}`, payload);
if (cmd === 'plugin:store|get') {
if (payload && 'key' in payload) {
const { key } = payload as { key: string };
const data = await get(storeKey(key));
if (data) {
return [data, true];
} else {
return [null, false];
}
}
} else if (cmd === 'plugin:store|set') {
if (payload && 'key' in payload && 'value' in payload) {
const { key, value } = payload as { key: string; value: any };
await set(storeKey(key), value);
return null;
}
} else if (cmd === 'plugin:store|keys') {
const allKeys = await keys();
const filtered = allKeys
.filter((key) => typeof key === 'string' && key.startsWith(STORE_PREFIX))
.map((key) => (key as string).substring(STORE_PREFIX.length));
return filtered;
} else if (cmd === 'plugin:store|delete') {
if (payload && 'key' in payload) {
const { key } = payload as { key: string };
await del(storeKey(key));
}
}
return null;
};This file is doing something very specific: it is not mocking my app. It is mocking the Tauri plugin protocol that my app already depends on.
That distinction matters, and it is also the part that generalizes cleanly beyond storage.
Instead of rewriting the application to speak some browser-specific storage API, I emulate the plugin commands that @tauri-apps/plugin-store expects. If the example were a different plugin, the same idea would still apply: preserve the plugin contract, then adapt the implementation underneath it.
Why IndexedDB Is a Good Fit Here
For the browser-side implementation, I use idb-keyval, a thin wrapper around IndexedDB.
That choice is specifically for the web fallback. In the native app, I still use the store plugin instead of talking to the embedded browser's IndexedDB directly, because file-backed persistence is the point of using the plugin in the first place. On the web, there is no equivalent filesystem-backed path available through Tauri, so the question changes from "what is identical?" to "what is the best persistence option that still serves the purpose well enough?"
That is a good match here because:
- it is asynchronous, like the native plugin boundary already is
- it persists across page reloads
- it is available in normal browsers without extra infrastructure
- it is simple enough that the mock stays small
I do not need to recreate every detail of the Tauri store plugin. I only need to implement the commands my app actually uses, and IndexedDB is good enough for that job.
The Command Mapping
With the storage choice out of the way, the rest is just command mapping. The mock currently handles four plugin commands:
plugin:store|getplugin:store|setplugin:store|keysplugin:store|delete
That command list effectively defines the storage contract that the rest of the frontend relies on.
plugin:store|get
if (cmd === 'plugin:store|get') {
if (payload && 'key' in payload) {
const { key } = payload as { key: string };
const data = await get(storeKey(key));
if (data) {
return [data, true];
} else {
return [null, false];
}
}
}The interesting part here is that the mock returns the shape expected by the plugin caller, not just the raw value.
That is exactly what makes this pattern work well. The mock should imitate the native protocol, not invent a more convenient browser-only protocol.
plugin:store|set
} else if (cmd === 'plugin:store|set') {
if (payload && 'key' in payload && 'value' in payload) {
const { key, value } = payload as { key: string; value: any };
await set(storeKey(key), value);
return null;
}
}This maps cleanly to IndexedDB. The plugin command carries a key and a value. The browser mock persists it using idb-keyval.
plugin:store|keys
} else if (cmd === 'plugin:store|keys') {
const allKeys = await keys();
const filtered = allKeys
.filter((key) => typeof key === 'string' && key.startsWith(STORE_PREFIX))
.map((key) => (key as string).substring(STORE_PREFIX.length));
return filtered;
}This is where the key prefix matters.
Since IndexedDB may contain other keys, I namespace everything for the mocked store and strip the prefix before returning results. That way the rest of the app sees the same logical store keys regardless of platform.
plugin:store|delete
} else if (cmd === 'plugin:store|delete') {
if (payload && 'key' in payload) {
const { key } = payload as { key: string };
await del(storeKey(key));
}
}Again, the goal is not sophistication. The goal is parity at the API boundary.
Why the Prefix Is More Important Than It Looks
This line is small but important:
const STORE_PREFIX = process.env.NEXT_PUBLIC_STORE_MOCK_PREFIX || 'store::';Without namespacing, the browser-backed mock can easily collide with other data living in IndexedDB.
By keeping a prefix:
- the mock store stays isolated
- multiple apps can coexist more safely in the same browser profile
- local debugging becomes easier because the stored keys are recognizable
- future migrations are easier because the storage domain is explicit
Using an environment variable for the prefix is also a good small flexibility point. It lets me isolate environments such as local development, staging, or different app variants without changing code.
What the Rest of the App Gets From This
Those lower-level details matter because they let the rest of the application stay boring. Once the provider exposes a loaded Store, most of the app does not care where that store came from.
A typical consumer looks like this in my codebase:
const { store, isMocked } = useTauriContext();Then it can do things like:
store.keys().then((keys) => {
const keyItems = keys.map((key) => ({ key }));
keyItems.sort((a, b) => a.key.localeCompare(b.key));
setStoreKeys(keyItems);
});or:
store.get(storeKey).then((value) => {
if (value) {
setStoreEntry({ key: storeKey, value });
}
});That code is not web-specific and not desktop-specific.
It is just application code.
That is the outcome I want: platform handling stays near the provider and the mock, not inside every feature module.
Native-Only Actions Can Still Exist
That separation also makes native-only actions easier to reason about. This pattern does not mean every feature must behave identically everywhere.
Some actions are inherently native. For example, one part of my app can ask Rust to open the on-disk store location:
const handleOpen = async () => {
await invoke('open_store_location');
};That should only be available when a real Tauri runtime exists.
So the UI can simply gate that action:
{!isMocked ? <Button onPress={handleOpen}>Open</Button> : null}That is a much better split than branching the whole feature. The feature still works in both environments. Only the truly native affordance disappears on the web.
Why I Still Prefer IPC Mocking
That is also why I still prefer IPC mocking over forking the storage layer. Another option would be to design a custom storage abstraction and swap implementations underneath it. That is reasonable in some apps, but here the frontend already trusts the plugin-store contract. Adding another wrapper would mostly mean maintaining one more compatibility layer.
Mocking IPC keeps the official plugin package in place, reuses the same call paths in native and web builds, and makes the browser fallback exercise something much closer to real behavior. That is the architectural win: preserve the contract the app already depends on, then swap the runtime behavior underneath it.
What You Still Need to Be Honest About
This pattern has limits.
- the web build only supports the commands you actually mock
- IndexedDB is a practical fallback, not the same thing as filesystem-backed persistence
- truly native actions such as shell integration, filesystem access, or direct Rust commands still need to be gated or replaced intentionally
If the mock drifts too far away from the native contract, it stops being a helpful bridge and starts becoming a second platform with its own rules. The safe version is the boring version: keep the mock small, focused, and protocol-accurate.
Where I Would Use It
The rule of thumb that falls out of all this is simple: mock at the IPC boundary, not at the feature boundary.
That is worth doing when the native and web apps still share most screens and business logic, and when only a limited set of native-only features need special handling. If I need more coverage later, I can extend the same idea to file import and export, selected invoke() commands, or capability flags, but only when the product actually needs them.
Final Takeaway
The point is not to make the web app pretend it is a full Tauri runtime. The point is to keep one frontend contract stable enough that native and web builds can share the same feature code.
In practice that means:
- native builds use the real Rust-backed path
- web builds intercept the same IPC calls in JavaScript
- the rest of the app keeps talking to the same plugin API
That is why this approach stays practical. I am not designing two frontend architectures and hoping they drift slowly. I am keeping one frontend architecture and only changing how that contract is served at runtime.