Architecture Modes
Multi-Client: API + Mobile + Desktop
One Go API serving an Expo mobile app AND a Wails desktop app -- all in a single monorepo with shared types. The pattern real SaaS products use (Linear, Notion, Slack).
Added in v3.9.0 via the --desktop flag.
Overview
The multi-client pattern combines a Go API with two or more native clients (mobile via Expo, desktop via Wails, optionally web via Next.js or TanStack). All clients share the same packages/shared/ types and schemas, and all talk to the same API over HTTP. No code duplication across clients.
This isn't a separate architecture mode -- it's a --desktop flag you combine with an existing architecture (--mobile, --triple, --double, or --api).
The command that started it all
grit new myapp --mobile --desktop --next
Scaffolds: Go API + Next.js web + admin panel + Expo mobile + Wails desktop, all in one monorepo, all sharing types.
When to Use This Pattern
Pick the multi-client pattern when you're building a product that needs to live in multiple places:
- Native apps AND a web presence -- e.g. a Slack-style team tool with desktop, mobile, and a browser fallback
- A mobile-first product that also needs a power-user desktop app -- e.g. a task manager like Things or Linear
- Any product where customers expect to pick up where they left off across phone and laptop -- e.g. chat apps, note-takers, calendars
- Enterprise tools where clients specifically request a native desktop installer instead of a browser tab
Don't use this for simple SaaS apps where a browser-only experience is enough -- stick with --triple. Don't use it for offline-first desktop apps either -- use grit new-desktop instead (standalone Wails + embedded Go + local SQLite).
--desktop vs grit new-desktop
These are two different desktop patterns. Pick the one that matches your product:
grit new-desktop | grit new --desktop | |
|---|---|---|
| Network | Offline-first, no remote API | Always online, calls remote API |
| Backend | Embedded in the desktop binary | Shared monorepo API (Gin + GORM) |
| Database | Local SQLite | Remote PostgreSQL via API |
| Wails bindings | Full CRUD, auth, exports (19+ methods) | Only native OS (window, file dialogs, keychain) |
| Token storage | Local-only session | OS keychain via 99designs/keyring |
| Works with mobile? | No -- it's standalone | Yes -- shares packages/shared with Expo |
| Use case | Invoicer, notes, local utilities | Multi-client SaaS, team tools |
Scaffold Combinations
The --desktop flag combines with every architecture mode except --single(single apps already bundle their own frontend). Here are the common setups:
1. Full multi-client SaaS (the Linear setup)
grit new myapp --triple --next --desktop
Scaffolds: apps/api, apps/web, apps/admin, apps/desktop, plus packages/shared. You get a web marketing site, an admin dashboard for ops, and a native desktop app -- all sharing one API.
2. Mobile + desktop with a marketing site
grit new myapp --triple --next --mobile --desktop
Scaffolds everything above PLUS apps/expo. This is the full Slack / Notion setup: every client surface.
3. Desktop + mobile, no web app
grit new myapp --mobile --desktop
Scaffolds apps/api, apps/expo, apps/desktop. Good for products that are native-only (no marketing page yet).
4. Minimal: API + desktop only
grit new myapp --api --desktop
Scaffolds apps/api and apps/desktop. The leanest multi-client setup.
5. Not allowed: --single --desktop
# This errors out:grit new myapp --single --desktop# → --desktop is not supported with --single architecture
Single-app architectures already bundle their own SPA into one Go binary. Adding a separate desktop client would be redundant. Use --triple or --api if you want a desktop app.
Project Structure
The shape of grit new myapp --triple --next --desktop --mobile:
myapp/├── apps/│ ├── api/ # Go backend (Gin + GORM + PostgreSQL)│ │ ├── cmd/server/main.go│ │ └── internal/│ │ ├── handlers/ # HTTP handlers│ │ ├── services/ # Business logic│ │ ├── models/ # GORM models│ │ └── routes/ # Route registration│ ├── web/ # Next.js marketing/landing│ ├── admin/ # Next.js admin dashboard│ ├── expo/ # Expo mobile (iOS + Android)│ │ ├── app/ # Expo Router screens│ │ └── lib/ # API client, auth, SecureStore│ └── desktop/ # Wails desktop (macOS/Windows/Linux)│ ├── main.go # Wails bootstrap, frameless window│ ├── app.go # Native OS bindings only│ ├── internal/│ │ └── keychain.go # OS keychain wrapper│ └── frontend/│ ├── src/│ │ ├── routes/ # TanStack Router file-based routes│ │ ├── components/ # Shared UI (layout, ui/, command-palette)│ │ ├── lib/ # API client, Wails bridge, shortcuts│ │ └── hooks/ # useAuth, useMe, useLogout│ └── vite.config.ts├── packages/│ └── shared/ # 🔑 Shared across ALL clients│ ├── schemas/ # Zod schemas (login, register, user...)│ ├── types/ # TypeScript types│ └── constants/ # ROUTES, ROLES├── docker-compose.yml├── turbo.json└── pnpm-workspace.yaml
The key insight: packages/shared is the glue. Change a Zod schema there and both the Expo app and the desktop app pick it up immediately. Change a Go model, run grit sync, and the TypeScript types regenerate for every client.
Development Workflow
With all apps running, you get live hot-reload across everything. Open four terminals (or use turbo dev):
# Terminal 1: Start infrastructure (Postgres, Redis, MinIO, Mailhog)docker compose up -d# Terminal 2: Go API (hot reload via air)cd apps/api && air# Terminal 3: Mobile app (Expo)cd apps/expo && npx expo start# Terminal 4: Desktop app (Wails)cd apps/desktop && wails dev# All of these can be run together with:pnpm dev
Or use the convenience scripts Grit adds to your root package.json:
pnpm dev # All apps via Turborepopnpm dev:api # Just the Go APIpnpm dev:web # Just the web frontendpnpm dev:admin # Just the admin panelpnpm dev:expo # Just the Expo apppnpm dev:desktop # Just the desktop app (wails dev)pnpm build:desktop # Build desktop app for production (wails build)
How Clients Share Code
The three client patterns (web, mobile, desktop) use the same primitives but with platform-appropriate adapters:
| Concept | Web | Mobile | Desktop |
|---|---|---|---|
| Types/schemas | packages/shared -- identical across all three | ||
| HTTP client | axios | axios | axios |
| Data fetching | TanStack Query | TanStack Query | TanStack Query |
| Forms | react-hook-form + zod | react-hook-form + zod | react-hook-form + zod |
| Router | Next.js App Router | Expo Router | TanStack Router |
| Token storage | js-cookie / localStorage | expo-secure-store | OS keychain (via Wails bridge) |
| Styling | Tailwind + shadcn/ui | NativeWind | Tailwind + custom primitives |
The useAuth hook signature is identical across all three: useMe(), useLogin(), useRegister(), useLogout(). Only the adapter (where tokens get stored) differs. Copy a feature from web to desktop and the structure stays the same.
What Ships in the Desktop App
The Wails desktop scaffold is intentionally minimal on the Go side and rich on the React side. Go handles only what the browser can't: window chrome, file dialogs, and OS keychain. Everything else lives in the React frontend and calls the API.
Go side (minimal)
main.go-- Wails bootstrap, frameless 1280x800 window (min 1000x700)app.go-- Native bindings: window controls, file dialogs, keychain, platform detectioninternal/keychain.go-- Cross-platform keychain via99designs/keyring
React side (TanStack Router)
- Custom title bar with platform-aware window controls (macOS traffic lights on the left, Windows/Linux controls on the right)
- Command palette (
Cmd+K/Ctrl+K) with keyboard-first navigation - Fixed 240px sidebar (not collapsible -- desktop windows are wide enough)
- Topbar with search, notifications, theme toggle, user menu
- Pre-built auth flow (login / register / forgot-password) using react-hook-form + zod
- Dashboard, Profile, Settings pages out of the box
- Global keyboard shortcuts via
useShortcuts()
Keyboard Shortcuts (Default)
| Shortcut | Action |
|---|---|
| ⌘K / Ctrl+K | Open command palette |
| ⌘, / Ctrl+, | Open Settings |
| ⌘L / Ctrl+L | Log out |
| Esc | Close modal / palette |
Add your own with the useShortcuts() hook:
import { useShortcuts } from "@/lib/use-shortcuts";useShortcuts({"mod+n": () => createNewItem(), // ⌘N / Ctrl+N"mod+shift+p": () => openProfile(), // ⌘⇧P / Ctrl+Shift+P"mod+slash": () => openHelp(), // ⌘/ / Ctrl+/});
Deploying a Multi-Client App
Each client ships differently. The shared API is deployed once, and the clients point to it:
1. API (one-time deploy)
grit deploy --host user@server.com --domain api.myapp.com
Cross-compiles, uploads via SCP, configures systemd + Caddy with auto-TLS.
2. Web / Admin (any time)
# Deploy to Vercel (pointing NEXT_PUBLIC_API_URL=https://api.myapp.com)cd apps/web && vercel --prodcd apps/admin && vercel --prod
3. Mobile (EAS Build)
cd apps/exponpx eas-cli build --platform iosnpx eas-cli build --platform androidnpx eas-cli submit --platform iosnpx eas-cli submit --platform android
4. Desktop (Wails build)
cd apps/desktopwails build -platform darwin/amd64 # macOS Intelwails build -platform darwin/arm64 # macOS Apple Siliconwails build -platform windows/amd64wails build -platform linux/amd64# Binaries land in build/bin/
Code-sign and notarize as needed (macOS) or sign with a certificate (Windows). Distribute as standalone binaries, via a download page, or through app stores.
Tips & Gotchas
- Set
VITE_API_URLin the desktop frontend's env when deploying. In dev, Vite proxies/apitolocalhost:8080, but once built, the frontend needs to know the full production URL. - CORS matters again. Native clients don't enforce CORS the way browsers do, but your API still needs to allow the desktop origin if you proxy through one. Usually fine with Grit's default CORS middleware; check if you customize.
- Keep Wails bindings minimal. Resist the urge to expose CRUD methods as Wails bindings -- it creates a second API surface to maintain. Do data work over HTTP, use bindings only for window/file/keychain.
- Dev without Wails. You can run the desktop frontend in a regular browser with
cd apps/desktop/frontend && pnpm dev(it falls back to localStorage for tokens). Useful for quick UI iteration without launching the full Wails window. - Match platform expectations. macOS users expect traffic lights on the left; Windows/Linux users expect close/min/max on the right. The scaffold handles this automatically via
getPlatform(). - Read the style guide. GRIT_STYLE_GUIDE.md §14.5 covers desktop-specific patterns: no breadcrumbs, no collapsible sidebar, 32px content padding (vs web's 24px), tighter focus rings, OS keychain only -- never localStorage.
Related Pages
- Triple architecture -- the base you'll usually combine
--desktopwith - Mobile architecture -- details on the Expo app
- API-only architecture -- for minimal API + desktop setups
- CLI reference -- full flag list