One Vue Codebase, Six Targets: How Quasar's Platform Modes Actually Fit Together
Quasar compiles one Vue 3 codebase into SPA, PWA, SSR, Electron, Capacitor, and browser extension builds. Here's how the platform isolation model works and where it breaks.
08 Aug 2025, 11:59 UTC

Shipping the same product as a web app, a desktop app, and a mobile app usually means three repos, three build pipelines, and three subtly diverging copies of every component. Quasar Framework takes a different bet: one Vue 3 codebase, one config file, and a CLI that compiles the same source into an SPA, PWA, SSR bundle, Electron app, Capacitor-powered iOS/Android app, or browser extension.
The thesis of this piece is simple: the value isn't that Quasar can build for six targets — it's that the platform differences are pushed into predictable, isolated places (src-{platform}/ folders and environment globals), so your shared code stays shared. Here's how that works in practice, where it breaks down, and how to verify it before committing your team to it.
One config file, many outputs
With Quasar CLI v2+ (the @quasar/app-vite package), Vite is the bundler for every mode. Your quasar.conf.js (or .ts) declares which modes you've enabled and holds per-platform settings — Electron bundler options, Capacitor app IDs, SSR middleware, PWA manifest — in one typed schema. Building is a flag change, not a config rewrite:
# Run from the project root; no elevated permissions needed
quasar build -m spa
quasar build -m pwa
quasar build -m ssr
quasar build -m electron
quasar build -m capacitor -T android
quasar build -m bex
Each command emits to its own folder under dist/ (dist/spa, dist/electron, dist/ssr, and so on). Because the same Vite pipeline feeds every target, you get fast HMR in dev and consistent tree-shaking in production regardless of mode. The practical win is reduced configuration drift: there's no separate webpack config for the desktop build quietly rotting in a corner.
Where platform‑specific code lives
Quasar's convention is that everything in src/ is shared — components, composables, Pinia stores, routes — while each platform gets its own top‑level folder that the CLI merges in at build time:
src-electron/— main and preload process filessrc-capacitor/—capacitor.config.jsonplus the generated native Xcode/Gradle projectssrc-ssr/— the Node.js server entry and middlewaresrc-bex/— manifest V3 files, background and content scripts
You never eject anything. The Electron main process can import your shared constants; the SSR entry reuses the same route definitions the SPA hydrates from. That last point matters more than it sounds: SSR and SPA share routing logic, so switching a page from client‑rendered to server‑rendered is a build‑mode decision, not a rewrite.
Runtime detection without stringly‑typed hacks
The build injects platform globals — process.env.ELECTRON, process.env.CAPACITOR, process.env.SERVER, process.env.CLIENT — which Vite statically replaces, so dead branches get eliminated per target. A small composable makes this ergonomic:
// src/composables/usePlatform.ts
export function usePlatform() {
const isElectron = !!process.env.ELECTRON
const isCapacitor = !!process.env.CAPACITOR
const isServer = !!process.env.SERVER
const isNativeShell = isElectron || isCapacitor
return { isElectron, isCapacitor, isServer, isNativeShell }
}
Because the replacement is static, if (process.env.ELECTRON) blocks vanish entirely from your web bundle — the desktop‑only code never ships to browsers.
The trade‑offs you should price in
- SSR hydration hazards. Shared components that touch
windoworlocalStorageat setup time will mismatch during hydration. Guard with the platform globals or Quasar's client‑only patterns. - Environment variable prefixing. Under Vite, custom env vars need the
VITE_prefix or explicit listing inquasar.conf.js > build > env. Teams migrating from webpack get bitten by this. - Native dependency hygiene. Electron‑only packages (like native Node modules) belong in
src-electron/or as optional dependencies, or they bloat — or break — your web bundles. - CI complexity. Capacitor iOS builds require macOS and Xcode, and each target is a separate pipeline job. "One codebase" does not mean "one build server."
- Extension isolation. In BEX mode, content scripts run in an isolated world; sharing Pinia state between a popup and a content script requires explicit messaging APIs, not automatic reactivity.
Verify it before you commit
Don't take the marketing diagram's word for it. Scaffold a throwaway project and compile every target:
npm create quasar@latest probe-app
# choose the Vite kit, TypeScript, your CSS flavor
cd probe-app
quasar mode add electron
quasar mode add capacitor
quasar mode add ssr
quasar mode add bex
quasar build -m spa && quasar build -m ssr && quasar build -m electron
Then check that dist/ contains the expected per‑mode folders, drop the usePlatform() composable into a component, and confirm each build reports the right platform. Run quasar dev -m ssr and quasar dev -m spa side by side and compare the network tab — SSR should deliver pre‑rendered HTML, the SPA a shell plus a JS bundle. Note that exact CLI prompts and defaults shift between releases, so treat the flag syntax above as a starting point and confirm against the installed CLI's --help.
The bottom line
Quasar's multi‑platform story holds up when your product genuinely shares most of its UI and logic across targets, and when you're disciplined about pushing platform concerns into the designated folders and env guards. If your mobile app and web app are really two products wearing the same logo, the shared‑codebase constraint will fight you. The actionable next step: spend one afternoon on the probe project above. If all six targets compile from one src/ and the platform composable behaves, you have real evidence — not a landing page — that the model fits your team.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.