Feature Flags in Vue 3: A Practical Guide
Feature flags let you ship code to production and decide later - or gradually - who actually sees it. In Vue 3 the pattern maps naturally onto composables: call useFeature in your <script setup>, get back a reactive ref, and branch your template on it.
This guide walks through everything from the initial plugin setup to Vue Router guards, Nuxt 3 integration, and writing tests that don't touch a real API.
Installation
pnpm add @supashiphq/vue-sdk
Install @supashiphq/vue-sdk from npm. The SDK requires Vue 3.3+. For Vue 2 projects, use @supashiphq/javascript-sdk directly. See the full Vue SDK reference for all composable signatures and TypeScript utilities.
Step 1: Register the plugin
createSupaship returns a standard Vue plugin. Install it in main.ts before mounting your app.
// main.ts
import { createApp } from 'vue'
import {
createSupaship,
FeaturesWithFallbacks,
InferFeatures,
} from '@supashiphq/vue-sdk'
import App from './App.vue'
// Define every flag your app uses along with its fallback value.
// The fallback is shown while flags are loading and if the API is unreachable.
const FEATURE_FLAGS = {
'new-header': false,
'show-announcement-bar': false,
'theme-config': { mode: 'light' as 'light' | 'dark', showLogo: true },
'beta-features': [] as string[],
} satisfies FeaturesWithFallbacks
// Type augmentation - makes useFeature fully type-safe throughout your app
declare module '@supashiphq/vue-sdk' {
interface Features extends InferFeatures<typeof FEATURE_FLAGS> {}
}
const app = createApp(App)
app.use(
createSupaship({
config: {
apiKey: import.meta.env.VITE_SUPASHIP_API_KEY,
environment: import.meta.env.MODE,
features: FEATURE_FLAGS,
},
}),
)
app.mount('#app')
Two things to notice:
satisfies FeaturesWithFallbackspreserves exact literal types, souseFeature('theme-config')returnsComputedRef<{ mode: 'light' | 'dark', showLogo: boolean }>rather than a generic object.- Fallback values are what users see while flags are loading and if the network is unavailable. Choose values that preserve your current behaviour.
Step 2: Toggle a single feature
<!-- components/AppHeader.vue -->
<script setup lang="ts">
import { useFeature } from '@supashiphq/vue-sdk'
const { feature: newHeader, isLoading } = useFeature('new-header')
</script>
<template>
<Skeleton v-if="isLoading" />
<NewHeader v-else-if="newHeader" />
<LegacyHeader v-else />
</template>
useFeature returns { feature, isLoading, isError, error, refetch }. feature is a ComputedRef - it's reactive, so the template updates automatically whenever the flag value changes.
Always handle isLoading. The SDK fetches flags asynchronously; rendering the wrong branch before the flag resolves causes a visible flash. Rendering a skeleton or nothing avoids it.
Step 3: Batch multiple flags
Each useFeature call is a separate network request. When a component needs several flags, use useFeatures to fetch them all in one go:
<!-- components/AppShell.vue -->
<script setup lang="ts">
import { useFeatures } from '@supashiphq/vue-sdk'
const { features, isLoading } = useFeatures([
'new-header',
'show-announcement-bar',
'beta-features',
])
</script>
<template>
<LoadingSpinner v-if="isLoading" />
<template v-else>
<AnnouncementBar v-if="features['show-announcement-bar']" />
<NewHeader v-if="features['new-header']" />
<LegacyHeader v-else />
<BetaBadge v-if="features['beta-features'].includes('early-access')" />
</template>
</template>
Step 4: Object-valued flags
Boolean flags answer "is this on?". Object flags let you carry configuration alongside the toggle - update the values from the dashboard without changing code:
<script setup lang="ts">
import { useFeature } from '@supashiphq/vue-sdk'
// theme-config is typed as { mode: 'light' | 'dark'; showLogo: boolean }
const { feature: theme } = useFeature('theme-config')
</script>
<template>
<div v-if="theme" :data-theme="theme.mode">
<Logo v-if="theme.showLogo" />
<slot />
</div>
</template>
This replaces a family of individual boolean flags (dark-mode-enabled, show-logo-in-dark-mode) with one structured value you can update atomically.
Step 5: User targeting with context
A flag evaluated with no context is a global on/off switch. Passing user context lets Supaship apply targeting rules - "only for Pro users", "roll out to 10%", "only in the EU".
Set the initial context at app start, then update it reactively when the user logs in:
// main.ts
app.use(
createSupaship({
config: {
apiKey: import.meta.env.VITE_SUPASHIP_API_KEY,
environment: import.meta.env.MODE,
features: FEATURE_FLAGS,
context: {
// Non-user context known at startup
appVersion: import.meta.env.VITE_APP_VERSION,
},
},
}),
)
<!-- composables/useAuthSync.ts -->
<script setup lang="ts">
import { watch } from 'vue'
import { useFeatureContext } from '@supashiphq/vue-sdk'
import { useAuth } from '@/composables/auth'
const { updateContext } = useFeatureContext()
const { user } = useAuth()
// Runs immediately and re-runs whenever user changes
watch(
user,
newUser => {
if (newUser) {
updateContext({
userId: newUser.id,
email: newUser.email,
plan: newUser.plan,
})
}
},
{ immediate: true },
)
</script>
Place <AuthSync /> near the top of your component tree (inside the plugin's scope) and every subsequent useFeature call will be evaluated against the current user.
Waiting for user data before fetching flags
If a flag depends on user data that isn't available immediately, use shouldFetch to defer the request:
<script setup lang="ts">
import { computed } from 'vue'
import { useFeatures } from '@supashiphq/vue-sdk'
import { useAuth } from '@/composables/auth'
const { user, isLoading: userLoading } = useAuth()
const { features, isLoading: flagsLoading } = useFeatures(
['premium-dashboard', 'beta-mode'],
{
context: computed(() => ({
userId: user.value?.id,
plan: user.value?.plan,
})),
// Don't fetch until the user is known
shouldFetch: computed(() => !userLoading.value && !!user.value),
},
)
const isLoading = computed(() => userLoading.value || flagsLoading.value)
</script>
<template>
<Skeleton v-if="isLoading" />
<PremiumDashboard v-else-if="features['premium-dashboard']" />
<StandardDashboard v-else />
</template>
Vue Router guards
Use a navigation guard to gate entire routes behind a flag. Users who don't have access are redirected before the route component ever loads:
// router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import { useClient } from '@supashiphq/vue-sdk'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/beta',
component: () => import('@/views/BetaView.vue'),
meta: { requiresFeature: 'beta-access' },
},
{
path: '/admin',
component: () => import('@/views/AdminView.vue'),
meta: { requiresFeature: 'admin-panel' },
},
],
})
router.beforeEach(async (to, _from, next) => {
const requiredFeature = to.meta.requiresFeature as string | undefined
if (!requiredFeature) return next()
try {
const client = useClient()
const enabled = await client.getFeature(requiredFeature)
return enabled ? next() : next('/404')
} catch {
return next('/error')
}
})
export default router
Nuxt 3 integration
In Nuxt 3, register the plugin as a client-only Nuxt plugin so it runs in the browser:
// plugins/supaship.client.ts
import { defineNuxtPlugin, useRuntimeConfig } from '#app'
import {
createSupaship,
FeaturesWithFallbacks,
InferFeatures,
} from '@supashiphq/vue-sdk'
const FEATURE_FLAGS = {
'new-homepage': false,
'dark-mode': false,
'announcement-banner': null,
} satisfies FeaturesWithFallbacks
declare module '@supashiphq/vue-sdk' {
interface Features extends InferFeatures<typeof FEATURE_FLAGS> {}
}
export default defineNuxtPlugin(nuxtApp => {
const config = useRuntimeConfig()
nuxtApp.vueApp.use(
createSupaship({
config: {
apiKey: config.public.supashipApiKey as string,
environment: process.env.NODE_ENV ?? 'production',
features: FEATURE_FLAGS,
},
}),
)
})
// nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
public: {
supashipApiKey: process.env.NUXT_PUBLIC_SUPASHIP_API_KEY ?? '',
},
},
})
Then use composables in any page or component as normal:
<!-- pages/index.vue -->
<script setup lang="ts">
import { useFeature } from '@supashiphq/vue-sdk'
const { feature: newHomepage, isLoading } = useFeature('new-homepage')
</script>
<template>
<Skeleton v-if="isLoading" />
<NewHomepage v-else-if="newHomepage" />
<LegacyHomepage v-else />
</template>
Updating context when page changes
For flags that target users based on the current page, sync the route into context using a watcher on useRoute:
<!-- App.vue -->
<script setup lang="ts">
import { watch } from 'vue'
import { useRoute } from 'vue-router'
import { useFeatureContext } from '@supashiphq/vue-sdk'
const route = useRoute()
const { updateContext } = useFeatureContext()
watch(
() => route.path,
newPath => {
updateContext({ currentPage: newPath })
},
{ immediate: true },
)
</script>
Development toolbar
The SDK ships a toolbar that lets you override flag values locally without touching the dashboard. It appears automatically on localhost:
app.use(
createSupaship({
config: { ... },
toolbar: {
enabled: 'auto', // 'auto' | true | false ('auto' = localhost only)
position: 'bottom-right',
},
}),
)
The toolbar shows all configured flags, their current values, and lets you override them per session. Overrides persist in localStorage and are cleared via the toolbar's reset button.
Testing with Vitest
Create a helper that wraps createSupaship with hardcoded feature values. Flags resolve instantly - no network, no mocking, no async setup:
// test-utils/setup.ts
import { createSupaship, FeaturesWithFallbacks } from '@supashiphq/vue-sdk'
export function createTestPlugin(features: FeaturesWithFallbacks = {}) {
return createSupaship({
config: {
apiKey: 'test-key',
environment: 'test',
features: { ...features } satisfies FeaturesWithFallbacks,
context: {},
},
})
}
// components/AppHeader.test.ts
import { mount } from '@vue/test-utils'
import { describe, it, expect } from 'vitest'
import { createTestPlugin } from '@/test-utils/setup'
import AppHeader from './AppHeader.vue'
describe('AppHeader', () => {
it('renders the new header when the flag is on', () => {
const wrapper = mount(AppHeader, {
global: {
plugins: [createTestPlugin({ 'new-header': true })],
},
})
expect(wrapper.findComponent({ name: 'NewHeader' }).exists()).toBe(true)
})
it('renders the legacy header when the flag is off', () => {
const wrapper = mount(AppHeader, {
global: {
plugins: [createTestPlugin({ 'new-header': false })],
},
})
expect(wrapper.findComponent({ name: 'LegacyHeader' }).exists()).toBe(true)
})
it('handles object-valued flags', () => {
const wrapper = mount(AppHeader, {
global: {
plugins: [
createTestPlugin({
'theme-config': { mode: 'dark', showLogo: false },
}),
],
},
})
expect(wrapper.find('[data-theme="dark"]').exists()).toBe(true)
expect(wrapper.findComponent({ name: 'Logo' }).exists()).toBe(false)
})
})
Each test declares its own flag state explicitly, making failures easy to diagnose and tests independent of each other.
Common pitfalls
Using useFeature outside a component
Composables must be called synchronously inside a component's setup() (or <script setup>). Calling them in a plain function or outside the component lifecycle will throw:
// ❌ BAD - outside a component
export async function loadData() {
const { feature } = useFeature('new-api') // throws
}
// ✅ GOOD - inside script setup
// components/MyComponent.vue
const { feature } = useFeature('new-api')
For use outside components (e.g. in a Pinia store or router guard), reach for useClient() and call client.getFeature() directly.
Forgetting to clean up stale flags
Once a rollout is complete, remove the flag from both your code and the Supaship dashboard. The type augmentation helps - deleting a flag from FEATURE_FLAGS turns every useFeature('that-flag') call into a TypeScript error, pointing you to every place that needs cleaning up.
What to do next
- Install
@supashiphq/vue-sdkand registercreateSupashipinmain.ts - Define your flags with
satisfies FeaturesWithFallbacksand add thedeclare moduleaugmentation - Use
useFeature/useFeaturesin components - Sync user context with
useFeatureContextafter login - Add Router guards for route-level access control
- Write tests with
createTestPlugin
Ready to try it? Create a free Supaship account and have your first Vue flag live in minutes. Free forever up to 1M events/month. Pro plan is $30/month with unlimited projects and team members. See the full Vue SDK reference and the targeting docs.
Related framework guides: Feature Flags in React · Feature Flags in Next.js · Feature Flags in Node.js
Comparing platforms? Supaship vs LaunchDarkly · Supaship vs Statsig · Supaship vs ConfigCat · Best Feature Flag Platforms 2026
Feedback
Got thoughts on this?
We're constantly learning how developers actually use these tools. Ideas, use cases, integration requests — every bit of feedback makes the platform better for everyone.
Thanks for being part of the journey — Supaship