Write Once, Deploy Everywhere: One Codebase, Four Platforms
NotepadXL Architecture Series
- Part 1: Why Every App Should Be a Plugin System
- Part 2: The Disposable Pattern — Zero Memory Leaks by Design
- Part 3: Write Once, Deploy Everywhere — One Codebase, Four Platforms (this post)
NotepadXL is one React/TypeScript codebase that compiles to a Tauri desktop app, an Electron desktop app, a static web app, and Capacitor mobile apps. The secret is a 479-line platform abstraction layer that makes file dialogs, clipboard, and system integration work identically across all four. Here is how it works, and the honest trade-offs we discovered.
The Architecture: A Shell with Wrappers
The project structure tells the story:
notepad-xl/
src/
web/ # The actual application (Next.js + React)
app/ # Pages and routing
components/ # UI components
lib/ # Business logic, plugin SDK, platform layer
tauri/ # Tauri desktop wrapper (Rust config)
electron/ # Electron desktop wrapper (Node.js)
android/ # Capacitor Android project
ios/ # Capacitor iOS project
plugins/
core/ # Built-in plugins
community/ # User-installed plugins
Everything lives in src/web/. This is a Next.js application that builds to a static export — a folder of HTML, CSS, and JavaScript files. The platform-specific directories (tauri/, electron/, android/, ios/) are thin wrappers that load those same static files inside their respective native containers.
The build commands from package.json make the relationship clear:
| Command | What It Does |
|---|---|
npm run build | Build the web app to src/web/out/ |
npm run tauri:build | Build web, then wrap in Tauri |
npm run electron:build | Build web, then wrap in Electron |
npm run capacitor:sync | Sync web build to mobile projects |
Every platform build starts with the same step: npm run build. The web app is the single source of truth.
The Platform Abstraction Layer
The key to making this work is platform.ts — a module that detects the current platform at runtime and provides unified APIs for operations that differ across environments.
Platform Detection
The detection logic is refreshingly simple:
export type PlatformType = 'tauri' | 'electron' | 'web';
export function getPlatformType(): PlatformType {
if (typeof window !== 'undefined') {
if ('__TAURI__' in window) {
return 'tauri';
}
if ('electronAPI' in window && window.electronAPI) {
return 'electron';
}
}
return 'web';
}
export function isTauri(): boolean {
return getPlatformType() === 'tauri';
}
export function isElectron(): boolean {
return getPlatformType() === 'electron';
}
When Tauri loads your web app, it injects a __TAURI__ global into the window object. When Electron loads it, the preload script exposes electronAPI. If neither is present, you are in a browser. Three lines of conditional logic, three platforms distinguished.
Insight
The 'web' return is the fallback, not a detection. This is deliberate — web is the least capable platform, so defaulting to it when detection fails is the safest option. You never accidentally grant native capabilities to a browser context.
Unified File Operations
File I/O is where the platforms diverge most. Here is how openFile handles all three:
export async function openFile(
options?: OpenOptions
): Promise<FileResult | null> {
if (isTauri()) {
// Tauri: Use native Rust-backed dialog and filesystem
const { open } = await import('@tauri-apps/plugin-dialog');
const { readTextFile } = await import('@tauri-apps/plugin-fs');
const selected = await open({
multiple: false,
filters: options?.filters || textFileFilters,
});
if (!selected || Array.isArray(selected)) return null;
const content = await readTextFile(selected);
const name = selected.split(/[\\/]/).pop() || 'Untitled';
return { path: selected, content, name, fileName: name };
} else if (isElectron() && window.electronAPI) {
// Electron: Use IPC to Node.js main process
const result = await window.electronAPI.openFile(options);
if (!result) return null;
const name = result.path.split(/[\\/]/).pop() || 'Untitled';
return { path: result.path, content: result.content, name, fileName: name };
} else {
// Web: Use the browser's file input element
return new Promise((resolve) => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.txt,.text,text/plain';
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) { resolve(null); return; }
const content = await file.text();
resolve({
path: file.name,
content,
name: file.name,
fileName: file.name,
});
};
input.click();
});
}
}
Three completely different mechanisms — Tauri's Rust-backed filesystem plugin, Electron's IPC to Node.js, and the browser's <input type="file"> element — hidden behind one function signature that returns the same FileResult type.
The same pattern applies to saveFile:
export async function saveFile(
content: string,
options?: SaveOptions
): Promise<string | null> {
if (isTauri()) {
const { save } = await import('@tauri-apps/plugin-dialog');
const { writeTextFile } = await import('@tauri-apps/plugin-fs');
const filePath = await save({
defaultPath: options?.defaultPath,
filters: options?.filters || textFileFilters,
});
if (!filePath) return null;
await writeTextFile(filePath, content);
return filePath;
} else if (isElectron() && window.electronAPI) {
const result = await window.electronAPI.saveFile(content, options);
return result === false ? null : result;
} else {
// Web: Download the file via a blob URL
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = options?.defaultPath || 'untitled.txt';
a.click();
URL.revokeObjectURL(url);
return options?.defaultPath || 'untitled.txt';
}
}
On Tauri, the user gets a native OS save dialog powered by Rust. On Electron, they get a Node.js-backed dialog. On web, the file is downloaded through the browser. From the calling code's perspective, the difference does not exist.
Clipboard Operations
Clipboard is simpler because the web API covers most cases, but Tauri provides a more reliable implementation:
export async function copyToClipboard(text: string): Promise<boolean> {
if (isTauri()) {
const { writeText } = await import(
'@tauri-apps/plugin-clipboard-manager'
);
await writeText(text);
return true;
} else {
await navigator.clipboard.writeText(text);
return true;
}
}
export async function readFromClipboard(): Promise<string | null> {
if (isTauri()) {
const { readText } = await import(
'@tauri-apps/plugin-clipboard-manager'
);
return await readText();
} else {
return await navigator.clipboard.readText();
}
}
Electron does not need special handling here — it falls through to the else branch and uses navigator.clipboard, which works in Electron's Chromium context.
Dialog Operations
One thing we learned early: never use alert(), confirm(), or prompt(). They behave differently across platforms and cannot be styled. Instead, NotepadXL uses custom modal dialogs everywhere:
export async function showConfirmDialog(
message: string,
title?: string
): Promise<boolean> {
const { showModalConfirm } = await import(
'@/components/ConfirmDialog'
);
const result = await showModalConfirm(
message, title || 'Notepad XL'
);
return result === 'confirm';
}
export async function showSaveConfirmDialog(
fileName: string
): Promise<SaveDialogResult> {
const { showModalSaveConfirm } = await import(
'@/components/ConfirmDialog'
);
return await showModalSaveConfirm(fileName) as SaveDialogResult;
}
By implementing dialogs as React components rather than platform-native dialogs, we get consistent behavior and appearance everywhere. The trade-off is that they do not look like native OS dialogs — but for an application that already has its own UI language, this is acceptable.
Why Tauri First, Electron as Fallback
NotepadXL supports both Tauri and Electron for desktop deployment. Why both? And why is Tauri the default?
| Factor | Tauri | Electron |
|---|---|---|
| Binary size | ~5-10 MB | ~150-200 MB |
| Memory usage | Lower (system webview) | Higher (bundled Chromium) |
| Backend language | Rust | Node.js |
| Security model | Granular permissions | Full Node.js access |
| Native module support | Via Rust crates | Via npm (better-sqlite3, etc.) |
| Platform webview | OS webview (varies) | Bundled Chromium (consistent) |
| Build complexity | Requires Rust toolchain | Node.js only |
Tauri is the default because the size and performance advantages are significant. A 10 MB text editor feels appropriate. A 200 MB text editor does not.
But Electron has real advantages that keep it as a supported target. The Electron configuration for NotepadXL shows some capabilities that are harder to achieve in Tauri:
// Electron main.js - SQLite integration
let Database;
try {
Database = require('better-sqlite3');
} catch (error) {
console.warn('better-sqlite3 not available:', error.message);
}
const databases = new Map();
ipcMain.handle('sqlite-open', async (event, dbPath) => {
try {
if (!Database) {
throw new Error('better-sqlite3 is not installed');
}
const dir = path.dirname(dbPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
const db = new Database(dbPath);
databases.set(dbPath, db);
return { success: true, dbId: dbPath };
} catch (error) {
return { success: false, error: error.message };
}
});
ipcMain.handle('sqlite-query', async (event, dbId, sql, params) => {
const db = databases.get(dbId);
if (!db) throw new Error('Database not found');
const stmt = db.prepare(sql);
const rows = stmt.all(...(params || []));
return { success: true, rows };
});
The Electron wrapper provides full SQLite database access through IPC handlers. Community plugins like a database viewer can execute SQL queries against local databases. This kind of deep native integration is possible in Tauri through Rust plugins, but Node.js's npm ecosystem makes it faster to prototype.
Insight
We keep both platform targets because they serve different needs. Tauri for distribution (small, fast, secure). Electron for development and when plugins need deep Node.js integration. The platform abstraction layer means the application code does not need to know which one it is running on.
The Tauri Configuration
The Tauri configuration lives in tauri.conf.json and defines how the desktop app behaves:
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Notepad XL",
"version": "1.1.0",
"identifier": "com.actyra.notepad-xl",
"build": {
"beforeDevCommand": {
"script": "npm run dev",
"cwd": "../web"
},
"devUrl": "http://localhost:3000",
"beforeBuildCommand": {
"script": "npm run build",
"cwd": "../web"
},
"frontendDist": "../web/out"
},
"app": {
"withGlobalTauri": true,
"windows": [
{
"title": "Notepad XL",
"width": 1000,
"height": 700,
"minWidth": 400,
"minHeight": 300,
"resizable": true,
"fullscreen": false,
"decorations": true
}
]
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}
The important fields:
frontendDist: "../web/out"— Tells Tauri where to find the built web app. This is the output ofnpm run buildin the web directory.beforeBuildCommand— Tauri automatically runs the web build before packaging. One command (npm run tauri:build) does everything.withGlobalTauri: true— Injects the__TAURI__global that our platform detection relies on.targets: "all"— Build for all supported platforms (MSI, AppImage, DMG, etc.)
The Electron Configuration
Electron's main process is a Node.js script that creates the browser window and sets up IPC:
const { app, BrowserWindow, ipcMain, dialog,
clipboard, shell, Menu } = require('electron');
const path = require('path');
const fs = require('fs');
function createWindow() {
// Remove default menu - we use our own React menu
Menu.setApplicationMenu(null);
mainWindow = new BrowserWindow({
width: 1000,
height: 700,
minWidth: 400,
minHeight: 300,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
nodeIntegration: false,
contextIsolation: true,
sandbox: false,
},
show: false,
backgroundColor: '#1e1e1e',
});
if (isDev) {
mainWindow.loadURL('http://localhost:3001');
mainWindow.webContents.openDevTools();
} else {
mainWindow.loadFile(
path.join(__dirname, 'out', 'index.html')
);
}
}
Notice the parallels with Tauri:
- Same window dimensions (1000x700, min 400x300)
- Same approach: load from dev server in development, load static files in production
- The menu bar is removed (
Menu.setApplicationMenu(null)) because NotepadXL uses its own React-based menu, not the native Electron menu
The IPC handlers mirror the platform abstraction layer. For every function in platform.ts, there is a corresponding IPC handler in Electron:
// File operations
ipcMain.handle('open-file', async (event, options) => {
const result = await dialog.showOpenDialog(mainWindow, {
filters: options?.filters || [
{ name: 'Text Files', extensions: ['txt', 'text'] },
{ name: 'All Files', extensions: ['*'] },
],
properties: ['openFile'],
});
if (result.canceled) return null;
const filePath = result.filePaths[0];
const content = fs.readFileSync(filePath, 'utf-8');
return { path: filePath, content };
});
// Clipboard
ipcMain.handle('read-clipboard', () => clipboard.readText());
ipcMain.handle('write-clipboard', (event, text) =>
clipboard.writeText(text)
);
// System integration
ipcMain.handle('open-external', (event, url) =>
shell.openExternal(url)
);
ipcMain.handle('open-path', (event, filePath) =>
shell.openPath(filePath)
);
The Next.js Static Export Strategy
NotepadXL uses Next.js, but it does not use a Node.js server in production. The key is static export:
// next.config.ts
const nextConfig = {
output: 'export', // Generate static HTML/CSS/JS
// ...
};
Running npm run build produces a folder of static files in src/web/out/. These files can be:
- Served by any web server (Nginx, Cloudflare Pages, S3, etc.)
- Loaded by Tauri from the local filesystem
- Loaded by Electron from the local filesystem
- Bundled into a Capacitor mobile app
No server-side rendering. No API routes. No server components in the traditional sense. The application is a client-side React app that happens to be built with Next.js for its excellent development experience, routing, and build tooling.
What Abstracts Cleanly (and What Does Not)
After building NotepadXL across four platforms, here is an honest assessment of what works and what requires compromises:
Abstracts Cleanly
File I/O
Open, save, read, write — all work identically. The return types are the same. Web uses download/upload; desktop uses native dialogs.
Clipboard
Read and write text. Tauri uses its plugin; web and Electron use the Clipboard API. Same result everywhere.
Dialogs
Custom React modal dialogs are platform-independent by definition. They look and work identically everywhere.
State Management
Zustand stores are pure JavaScript. No platform dependency. Settings persist to localStorage on all platforms.
Requires Compromise
| Feature | Challenge | Our Solution |
|---|---|---|
| Native menus | OS menus are structurally different on macOS vs Windows vs web | Use React menu bar everywhere, disable native menus |
| Window management | Opening new windows works differently on each platform | Platform-specific code with WebviewWindow for Tauri, window.open() for web |
| File watching | Web cannot watch the filesystem; desktop can | Feature only available on desktop platforms |
| Database access | SQLite requires native bindings (Electron) or Rust (Tauri) | Electron provides better-sqlite3 via IPC; web uses sql.js (WASM) |
| Direct file paths | Web browsers do not expose real file paths | Web gets filenames only; desktop gets full paths |
The window management code is a good example of where the abstraction leaks:
export async function openNewWindow(): Promise<void> {
if (isTauri()) {
try {
const { WebviewWindow } = await import(
'@tauri-apps/api/webviewWindow'
);
const windowId = `notepad-${Date.now()}`;
new WebviewWindow(windowId, {
title: 'Notepad XL',
width: 800,
height: 600,
center: true,
});
} catch (error) {
console.error('Failed to open new window:', error);
// Fallback to browser behavior
window.open(window.location.href, '_blank');
}
} else {
window.open(window.location.href, '_blank');
}
}
Tauri creates a genuine new application window with its own webview. Web and Electron open a browser tab or window. The user experience is different even though the function signature is the same. This is an honest trade-off: the abstraction provides a unified API, but the underlying behavior is not identical.
Dynamic Imports: The Performance Pattern
One pattern you will notice throughout platform.ts is the use of dynamic import() for platform-specific modules:
if (isTauri()) {
const { open } = await import('@tauri-apps/plugin-dialog');
const { readTextFile } = await import('@tauri-apps/plugin-fs');
// ...
}
This is not just for code organization. It is a build optimization. The Tauri plugins (@tauri-apps/plugin-dialog, @tauri-apps/plugin-fs, @tauri-apps/plugin-clipboard-manager) should not be bundled when building for Electron or web. Dynamic imports ensure they are only loaded when the code actually runs on Tauri.
Similarly, the Electron IPC calls go through window.electronAPI, which only exists when the Electron preload script has run. No Electron-specific imports appear in the web codebase at all — the bridge is entirely through the window object.
Insight
Dynamic imports serve two purposes here: code splitting (do not bundle what you do not need) and runtime safety (do not import a module that does not exist on this platform). The if (isTauri()) guard ensures the import only executes in the correct context, and Next.js's tree-shaking removes the dead code from other platform builds.
The Build Matrix
Here is the complete set of build commands and what they produce:
// Web (any browser, static hosting)
npm run build
// Output: src/web/out/ (static HTML/CSS/JS)
// Tauri Desktop
npm run tauri:build
npm run tauri:build:windows // Windows .exe + .msi
npm run tauri:build:mac // macOS .dmg + .app
npm run tauri:build:linux // Linux .AppImage + .deb
// Electron Desktop
npm run electron:build
npm run electron:build:win // Windows installer
npm run electron:build:mac // macOS build
npm run electron:build:linux // Linux build
// Mobile
npm run capacitor:sync // Sync web build to native projects
npm run capacitor:android // Open in Android Studio
npm run capacitor:ios // Open in Xcode
The Tauri build targets specific architectures because Rust compiles to native code:
"tauri:build:windows":
"npm run build && cd src/tauri && npx tauri build --target x86_64-pc-windows-msvc",
"tauri:build:mac":
"npm run build && cd src/tauri && npx tauri build --target aarch64-apple-darwin",
"tauri:build:linux":
"npm run build && cd src/tauri && npx tauri build --target x86_64-unknown-linux-gnu"
Every command starts with npm run build to generate the web app, then wraps it. The web build is always the first step. It is not possible to build a Tauri or Electron distribution without first building the web app.
Filesystem Operations: Desktop Only
Some operations only make sense on desktop platforms. The platform layer handles this gracefully by returning null or false on unsupported platforms:
export async function saveFileToPath(
path: string,
content: string
): Promise<boolean> {
if (isTauri()) {
const { writeTextFile } = await import('@tauri-apps/plugin-fs');
await writeTextFile(path, content);
return true;
} else if (isElectron() && window.electronAPI) {
return await window.electronAPI.writeFile(path, content);
}
// Web can't save to specific paths
return false;
}
export async function getPluginsDir(): Promise<string | null> {
if (isTauri()) {
const { appDataDir, join } = await import('@tauri-apps/api/path');
const appData = await appDataDir();
return await join(appData, 'plugins');
} else if (isElectron() && window.electronAPI) {
return await api.getPluginsDir();
}
return null; // Web has no concept of a plugins directory
}
This is an honest abstraction. It does not pretend that web browsers can write to arbitrary file paths. It returns false and lets the calling code handle the limitation. The plugin system, for example, uses this to determine whether to offer "Install from ZIP" functionality — it only works on desktop.
The Honest Trade-Offs
Cross-platform development is about trade-offs. Here is what we gained and what we gave up with NotepadXL's approach.
What We Gained
- One codebase to maintain. Bug fixes, new features, and plugin SDK changes are written once and deployed everywhere.
- Web-first development. The development loop is
npm run devand a browser. No compilation, no native toolchains, instant hot reload. Desktop and mobile are tested after the feature works on web. - Shared plugin ecosystem. Plugins work identically on all platforms. A community plugin author writes one implementation that runs on web, Tauri, and Electron.
- Progressive enhancement. The web version works everywhere. Desktop adds native file access, window management, and database support. Mobile adds touch support. Each platform adds capabilities without changing the core.
What We Lost
- True native feel. A React menu bar will never feel exactly like a Windows menu bar or a macOS menu bar. We accepted this trade-off because consistency across platforms is more valuable for our use case than native feel on any single platform.
- Platform-specific optimizations. A native macOS text editor could use Core Text for rendering. A native Windows editor could use DirectWrite. Our web-based editor uses a
<textarea>with CSS styling. It works well, but it will never be as performant as native text rendering for very large files. - Webview inconsistencies. Tauri uses the OS webview (WebKit on macOS, WebView2 on Windows, WebKitGTK on Linux). These webviews have slightly different rendering behaviors. Electron avoids this by bundling Chromium, but at a significant size cost.
- Build complexity. Building for four platforms means maintaining four sets of build configurations, four sets of platform-specific code paths, and testing on four environments. The platform abstraction layer simplifies the application code, but the build infrastructure is genuinely complex.
The Verdict
For applications where the UI is already custom (not native OS widgets), where the primary value is in the application logic rather than platform integration, and where reaching multiple platforms from a small team is a requirement — the write-once approach is worth the trade-offs. NotepadXL is that kind of application. A system-level utility or a media editing app might not be.
Wrapping Up the Series
Across these three posts, we have covered the architecture of NotepadXL from philosophy to implementation:
- Part 1 made the case for plugin-first architecture — treating every feature as a plugin, using manifests as contracts, and building trust boundaries through the tier system.
- Part 2 went deep on the Disposable pattern — the mechanism that makes the plugin system safe by making the easy path also the safe path.
- Part 3 (this post) covered the cross-platform story — how one codebase reaches four platforms through a platform abstraction layer, and the trade-offs involved.
The three ideas are connected. Plugin-first architecture means clean boundaries. Clean boundaries mean a platform abstraction layer can work (plugins do not depend on platform-specific details). The Disposable pattern means plugins from unknown authors are safe regardless of which platform they run on.
These are not new ideas. VS Code pioneered many of them. What NotepadXL demonstrates is that you can apply them to a smaller project — a text editor, not an IDE — and the benefits still outweigh the costs. If you are building something that has features, that might run on multiple platforms, and that might someday have third-party extensions, consider making everything a plugin from day one. The patterns exist. The TypeScript types make them safe. And the result is an application that is more maintainable, more testable, and more user-controllable than the monolith you would have built otherwise.
NotepadXL Architecture Series
- Part 1: Why Every App Should Be a Plugin System
- Part 2: The Disposable Pattern — Zero Memory Leaks by Design
- Part 3: Write Once, Deploy Everywhere — One Codebase, Four Platforms (this post)
This is part of my daily developer log. Follow my journey as I learn new skills and build tools with Brian at Actyra.