Quasar v2 Multi-Target Builds: Isolating Platform Code Without Duplication
Quasar v2's mode-based CLI builds SPA, SSR, PWA, Capacitor, Electron, and BEX from one codebase. This guide shows how to isolate platform-specific dependencies (like SSR-only sharp) using build-time QUASAR_MODE guards and runtime Platform.is checks, with worked config examples and common pitfalls.
02 Jul 2025, 11:53 UTC

The Problem: One Codebase, Six Runtime Targets
Building a Vue application that runs as a SPA, SSR site, PWA, Capacitor mobile app, Electron desktop app, and browser extension usually means either duplicating logic across repos or stuffing every platform's dependencies into a single bundle. Quasar Framework v2 solves this with a mode-based CLI that compiles separate output trees from shared source, while letting you gate platform-specific code at build time and runtime.
How the Mode System Works
Every Quasar command accepts a -m flag that selects the target:
quasar dev -m spa # development server for SPA
quasar build -m ssr # production SSR bundle
quasar build -m capacitor -T android # Android APK via Capacitor
quasar build -m electron # Electron desktop app
quasar build -m pwa # PWA with service worker
quasar build -m bex # Browser extension (Manifest V3)
Each mode writes to its own dist/ subfolder (dist/spa/, dist/ssr/, dist/pwa/, etc.). The CLI loads mode-specific configuration from quasar.conf.js — top-level keys like ssr, pwa, capacitor, electron, bex — and only includes the relevant boot files, plugins, and webpack/Vite extensions for that build.
Platform Detection: Build-Time vs Runtime
Two mechanisms let you branch code:
- process.env.QUASAR_MODE — replaced at compile time with the current mode string (
'ssr','capacitor', etc.). Use it in top-levelifblocks so the bundler can tree-shake dead branches. - Platform.is.* — runtime booleans (
Platform.is.capacitor,Platform.is.electron,Platform.is.client,Platform.is.server) fromquasar. Use these inside component logic where the condition isn't known until execution.
Mixing them incorrectly defeats tree-shaking. A dynamic require() or variable-based check keeps both branches in the bundle.
Worked Example: SSR-Only Dependency (sharp) Without Polluting Other Builds
Image processing with sharp is Node-only. You want it in the SSR server but never in the SPA, PWA, or mobile bundles.
1. Extend the SSR webpack config to externalize sharp
// quasar.conf.js
module.exports = function (/* ctx */) {
return {
// ...
build: {
extendWebpack(cfg, { isServer }) {
if (isServer) {
// Prevent sharp from being bundled; it'll be required at runtime
cfg.externals = cfg.externals || []
cfg.externals.push('sharp')
}
}
},
ssr: {
// SSR-specific settings
pwa: false
}
}
}
2. Create a boot file that only runs in SSR mode
// src/boot/sharp.js
// This file is only imported when QUASAR_MODE === 'ssr'
if (process.env.QUASAR_MODE === 'ssr') {
// Dynamic import ensures the module is never analyzed for other modes
import('sharp').then((sharp) => {
// Attach to global or provide via injection for server routes
globalThis.$sharp = sharp.default
})
}
3. Register the boot file conditionally
// quasar.conf.js (continued)
boot: [
'axios',
'pinia',
// Only include sharp boot in SSR build
process.env.QUASAR_MODE === 'ssr' ? 'sharp' : ''
].filter(Boolean)
Result: sharp appears in dist/ssr/ (as an external require) but is absent from dist/spa/, dist/pwa/, dist/capacitor/, and dist/electron/. The if (process.env.QUASAR_MODE === 'ssr') guard is evaluated at compile time, so Vite/Rollup drops the entire import for other modes.
Per-Target Configuration Essentials
SSR: Node Entry and Hydration Guards
SSR adds src-ssr/ with a customizable Express server (src-ssr/extension.js). The same Vue components render on server and client, but browser-only APIs (window, localStorage, document) must be guarded:
// Inside a component setup()
import { onMounted } from 'vue'
import { Platform } from 'quasar'
// ❌ Breaks hydration — runs on server
const width = window.innerWidth
// ✅ Runs only after client mount
onMounted(() => {
const width = window.innerWidth
})
// ✅ Runtime guard
if (Platform.is.client) {
// safe to use localStorage
}
Hydration mismatches often come from Date.now(), Math.random(), or third-party libraries that access browser globals in setup(). Move them to onMounted() or use useQuasar().meta for head tags.
PWA: Workbox Service Worker Configuration
PWA mode injects a generated service worker via @quasar/pwa. Configure caching in quasar.conf.js:
pwa: {
workboxPluginMode: 'GenerateSW',
workboxOptions: {
cleanupOutdatedCaches: true,
runtimeCaching: [
{
urlPattern: /^https:\/\/api\.example\.com/,
handler: 'StaleWhileRevalidate',
options: { cacheName: 'api-cache' }
}
]
},
manifest: {
name: 'My App',
short_name: 'App',
theme_color: '#1976d2'
}
}
Critical: The service worker only registers on the production build served over HTTPS (or localhost). Run quasar serve dist/pwa to test offline behavior; the dev server does not register a SW.
Capacitor: Native Project Sync and Plugin Guards
Running quasar build -m capacitor -T android (or -T ios) generates native projects under src-capacitor/ and runs capacitor sync automatically. Native plugins (Camera, Biometrics, etc.) must be installed in src-capacitor/:
cd src-capacitor
npm install @capacitor/camera
npx cap sync
In your Vue code, wrap plugin usage so the browser dev server doesn't throw:
import { Capacitor } from '@capacitor/core'
import { Camera } from '@capacitor/camera'
import { Platform } from 'quasar'
async function takePhoto() {
if (!Platform.is.capacitor) {
// Fallback for browser: use input type=file
return
}
const photo = await Camera.getPhoto({ quality: 90 })
// ...
}
Electron: Secure IPC via Preload Script
Electron mode bundles src-electron/main.js (main process) and src-electron/preload.js (preload). The default nodeIntegration: false isolates the renderer; expose safe APIs through contextBridge:
// src-electron/preload.js
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('api', {
readConfig: () => ipcRenderer.invoke('read-config'),
onMenuAction: (cb) => ipcRenderer.on('menu-action', cb)
})
// Renderer (Vue component)
const config = await window.api.readConfig()
Enabling nodeIntegration: true re-introduces security risks and breaks contextBridge — avoid it.
Common Mistakes and Limits
Tree-Shaking Only Works on Static Top-Level Conditions
This removes the else branch:
if (process.env.QUASAR_MODE === 'ssr') {
import('sharp')
}
This keeps both branches:
const mode = process.env.QUASAR_MODE
if (mode === 'ssr') { ... } // variable defeats static analysis
require(process.env.QUASAR_MODE === 'ssr' ? 'sharp' : 'noop') // dynamic require
SSR Hydration Mismatches from Non-Deterministic Code
Any non-idempotent call in setup() (timestamps, random IDs, browser-only libs) causes a mismatch. The server renders one DOM, the client another. Fix: defer to onMounted() or compute on the server and pass via props.
Capacitor Plugins Throw in Browser Dev Mode
Even with Platform.is.capacitor guards, importing a plugin that loads native code at module evaluation can crash the dev server. Use dynamic imports inside the guard:
if (Platform.is.capacitor) {
const { Camera } = await import('@capacitor/camera')
// use Camera
}
PWA Service Worker Caches Only Production Assets
Workbox generates sw.js pointing to hashed filenames in dist/pwa/. Testing requires a static file server (quasar serve dist/pwa or npx serve dist/pwa). The dev server (quasar dev -m pwa) does not register the SW.
Electron nodeIntegration: false Is Default — Keep It That Way
Turning it on lets renderer code require Node modules directly, but disables contextBridge and exposes the full Node API to untrusted content. Use preload IPC for all native access.
Verifying Your Multi-Target Build
- Inspect output folders: After
quasar build -m all(or each mode individually), checkdist/spa/,dist/ssr/,dist/pwa/,dist/electron/,dist/capacitor/. Confirm SSR-only deps (likesharp) are absent from SPA/PWA bundles. - PWA offline test: Run
quasar serve dist/pwa, open in Chrome, go to DevTools → Application → Service Workers. Verify registration, then check Offline and reload. - Electron IPC test:
quasar build -m electron, then launchdist/electron/output. Open DevTools (Ctrl+Shift+I) and callwindow.api.readConfig()in console. - Capacitor native sync: After
quasar build -m capacitor -T android, opensrc-capacitor/android/in Android Studio and verify plugins appear incapacitor.config.ts. - Version compatibility: Run
node -v(must be ≥18) andnpx vite --version(must be ≥5) inside the project. Quasar v2 fails obscurely on older Node/Vite.
When to Use This Pattern
Quasar's multi-target CLI shines when you need the same business logic and UI components across web, mobile, and desktop with minimal platform-specific code. The mode flag and QUASAR_MODE replacement give you compile-time isolation; Platform.is.* handles runtime branching. The trade-off is a single quasar.conf.js that grows with each target — keep it organized by extracting mode-specific objects into separate files if it exceeds ~300 lines.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.