In the previous post, I explained why I built data-peek. Now let's talk about how I built it.
Choosing a tech stack for a desktop app in 2025 is... interesting. You've got Electron, Tauri, Flutter, native frameworks, and a dozen other options. Here's what I chose and why.
┌─────────────────────────────────────────────────────────────┐
│ DESKTOP APPLICATION │
├─────────────────────────────────────────────────────────────┤
│ Frontend (Renderer Process) │
│ ├── React 19 UI framework │
│ ├── TypeScript Type safety │
│ ├── Zustand State management │
│ ├── Monaco Code editor │
│ ├── TanStack Table Data grid │
│ ├── TanStack Router Navigation │
│ ├── shadcn/ui Component library │
│ ├── Tailwind CSS 4 Styling │
│ └── @xyflow/react ERD visualization │
├─────────────────────────────────────────────────────────────┤
│ Backend (Main Process) │
│ ├── Electron 38 Desktop runtime │
│ ├── pg PostgreSQL client │
│ ├── mysql2 MySQL client │
│ ├── mssql SQL Server client │
│ ├── better-sqlite3 Local storage │
│ ├── electron-store Encrypted settings │
│ └── Vercel AI SDK LLM integration │
├─────────────────────────────────────────────────────────────┤
│ Build & Development │
│ ├── electron-vite Fast bundling │
│ ├── pnpm workspaces Monorepo management │
│ ├── Vitest Testing │
│ └── electron-builder Distribution │
└─────────────────────────────────────────────────────────────┘
Let me walk through the key decisions.
Why Electron?
I know, I know. "Electron apps are bloated." "Use Tauri instead." I've heard it all.
Here's why I still chose Electron:
1. Mature Ecosystem
Electron has been around since 2013. VS Code, Slack, Discord, Figma (desktop), Notion... all Electron. That means:
- Battle-tested in production at scale
- Extensive documentation
- Solutions for almost every problem on Stack Overflow
- Large ecosystem of tools (electron-builder, electron-store, etc.)
2. Node.js Integration
I need to run database drivers. pg, mysql2, and mssql are Node.js packages. Electron gives me full Node.js in the main process.
With Tauri, I'd need to:
- Write database connections in Rust
- Bridge Rust to JavaScript
- Maintain two language codebases
For a solo developer, that's a non-starter.
3. Development Speed
electron-vite gives me:
- Hot module replacement in the renderer
- Fast rebuilds (~100ms)
- TypeScript out of the box
- Same dev experience as a web app
I can iterate faster with Electron than any alternative.
4. Cross-Platform for Free
One codebase runs on macOS (Intel + Apple Silicon), Windows, and Linux. electron-builder handles code signing, notarization, auto-updates, and installers.
The Size Trade-off
Yes, Electron bundles Chromium. The app is ~150MB. But in 2025:
- Storage is cheap
- Download speeds are fast
- Users don't care about 150MB (they care about functionality)
I'd rather ship a working app than a small broken one.
React 19: The UI Layer
React 19 brought some nice improvements:
Concurrent Features
// Suspense for data loading
<Suspense fallback={<SchemaLoader />}>
<SchemaExplorer />
</Suspense>
useTransition for Responsive UI
const [isPending, startTransition] = useTransition()
const handleSearch = (query: string) => {
startTransition(() => {
setSearchResults(filterSchemas(query))
})
}
Large schema lists (1000+ tables) stay responsive during filtering.
Why Not Vue/Svelte/Solid?
React's ecosystem is unmatched for what I needed:
-
Monaco React bindings -
@monaco-editor/reactjust works - TanStack - Router and Table are React-first
- shadcn/ui - It is slowly becoming the trivial solution when crafting interfaces.
- Familiarity - I've shipped React apps for years
Could I build this in Vue? Sure. But I'd spend more time on tooling and less on features.
TypeScript: Strict Mode or Bust
Every .ts and .tsx file in data-peek is strictly typed:
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noUncheckedIndexedAccess": true
}
}
Why It Matters
In a database client, type safety prevents disasters:
// Without strict mode, this compiles fine and crashes at runtime
function formatCell(value: unknown) {
return value.toString() // 💥 value might be null
}
// With strict mode, TypeScript forces you to handle it
function formatCell(value: unknown) {
if (value === null) return 'NULL'
if (value === undefined) return ''
return String(value)
}
Shared Types Package
The IPC contract between main and renderer is defined in a shared package:
packages/shared/src/index.ts
export interface Connection {
id: string
name: string
host: string
port: number
database: string
username: string
password: string
dbType: 'postgresql' | 'mysql' | 'mssql'
ssl?: boolean
}
export interface QueryResult {
rows: Record<string, unknown>[]
fields: FieldInfo[]
rowCount: number
duration: number
}
Both processes import from @shared/*. If I change a type, TypeScript catches mismatches at compile time—not when a user runs a query.
Zustand: State Management That Doesn't Hurt
I've used Redux, MobX, Recoil, Jotai, and Context. Zustand is my favorite for mid-size apps.
11 Focused Stores
src/renderer/src/stores/
├── connection-store.ts # Active connection, available connections
├── query-store.ts # Query history, execution state
├── tab-store.ts # Editor tabs
├── edit-store.ts # Pending row edits
├── ddl-store.ts # Table designer state
├── ai-store.ts # AI chat sessions
├── settings-store.ts # User preferences
├── license-store.ts # License status
├── saved-queries-store.ts # Bookmarked queries
├── sidebar-store.ts # Sidebar state
└── schema-store.ts # Cached schema info
Simple API
// Define a store
export const useConnectionStore = create<ConnectionState>()(
persist(
(set, get) => ({
connections: [],
activeConnection: null,
setActiveConnection: (conn) => set({ activeConnection: conn }),
addConnection: async (conn) => {
const connections = [...get().connections, conn]
set({ connections })
},
}),
{ name: 'connections' }
)
)
// Use it anywhere
function ConnectionSelector() {
const { connections, activeConnection, setActiveConnection } = useConnectionStore()
// ...
}
No providers, no boilerplate, no action creators. Just functions.
Persistence Built-In
persist(
(set, get) => ({ /* ... */ }),
{ name: 'connections' } // Saves to localStorage
)
Query history, saved queries, and settings survive app restarts.
Monaco: VS Code's Editor in Your App
Monaco is the editor that powers VS Code. It gives data-peek:
- Syntax highlighting for SQL (PostgreSQL, MySQL, T-SQL)
- Multi-cursor editing
- Find and replace with regex
- Keyboard shortcuts users already know
- Theming that matches the app
Custom Configuration
<MonacoEditor
language="sql"
theme={isDark ? 'vs-dark' : 'vs'}
options={{
minimap: { enabled: false },
lineNumbers: 'on',
fontSize: 14,
tabSize: 2,
wordWrap: 'on',
automaticLayout: true,
}}
onChange={handleQueryChange}
/>
Schema-Aware Autocomplete
Monaco's completion provider lets me inject schema context:
monaco.languages.registerCompletionItemProvider('sql', {
provideCompletionItems: (model, position) => {
const suggestions = getTableAndColumnSuggestions(currentSchema)
return { suggestions }
}
})
Type users. and see column suggestions. Type FROM and see table names.
shadcn/ui + Tailwind CSS 4
I didn't want to build a design system from scratch. shadcn/ui provides:
- Copy-paste components - Not a npm dependency, actual source code
- Radix primitives - Accessible by default
- Tailwind styling - Customizable without fighting CSS
Component Examples
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">New Connection</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Connection</DialogTitle>
</DialogHeader>
<ConnectionForm onSubmit={handleAdd} />
</DialogContent>
</Dialog>
Tailwind CSS 4
The new version has:
-
CSS-first config - No more
tailwind.config.js - Native CSS variables - Better theming
- Smaller output - Only includes used classes
@theme {
--color-primary: oklch(0.7 0.15 250);
--color-background: oklch(0.98 0 0);
--color-foreground: oklch(0.1 0 0);
}
Dark mode is a single class toggle:
<html className={theme === 'dark' ? 'dark' : ''}>
@xyflow/react: ERD Visualization
Building an Entity Relationship Diagram from scratch would take weeks. @xyflow/react (formerly React Flow) gave me:
- Draggable nodes for tables
- Edges for foreign key relationships
- Mini-map for navigation
- Zoom/pan controls
- Custom node components
I added collision detection on top (more on that in a future post).
The Monorepo Structure
data-peek/
├── apps/
│ ├── desktop/ # Electron app
│ └── web/ # License API + marketing site
├── packages/
│ └── shared/ # Shared TypeScript types
├── docs/ # Documentation
├── seeds/ # Test database seeds
├── pnpm-workspace.yaml
└── package.json
Why pnpm?
- Fast - Symlinks instead of copying
- Strict - No phantom dependencies
- Workspaces - Native monorepo support
Workspace Commands
# Run dev for desktop only
pnpm --filter @data-peek/desktop dev
# Build all workspaces
pnpm build
# Lint everything
pnpm lint
Build & Distribution
electron-vite
Vite-based bundling for Electron:
// electron.vite.config.ts
export default defineConfig({
main: {
build: { rollupOptions: { external: ['pg', 'mysql2', 'mssql'] } }
},
preload: {
build: { rollupOptions: { external: ['electron'] } }
},
renderer: {
plugins: [react()],
resolve: { alias: { '@': resolve('src/renderer/src') } }
}
})
Hot reload in the renderer, fast rebuilds in main process.
electron-builder
Handles everything for distribution:
# electron-builder.yml
appId: com.datapeek.app
productName: data-peek
mac:
target: [dmg, zip]
hardenedRuntime: true
notarize: true
win:
target: [nsis]
linux:
target: [AppImage, deb, tar.gz]
publish:
provider: github
One command builds for all platforms:
pnpm build:mac
pnpm build:win
pnpm build:linux
What I'd Do Differently
Consider Wails for v2
If I were starting fresh with more time, I'd seriously look at Wails. Since it uses Go for the backend, I could leverage my Go experience and avoid the Rust learning curve. The smaller binary size compared to Electron is compelling too.
More Tests from Day One
I added tests late. The SQL builder and parser now have good coverage, but UI tests are sparse.
Conclusion
The tech stack for data-peek prioritizes:
- Developer productivity - Fast iteration, familiar tools
- User experience - Responsive UI, native feel
- Maintainability - Type safety, clear architecture
- Cross-platform - One codebase, three platforms
Is it the "optimal" stack? Probably not. But it's the stack that let me ship a working product.
Next up: Supporting 3 Databases with One Codebase: The Adapter Pattern
Top comments (0)