Feature Flags in Vue 3: A Practical Guide

Supa DeveloperSupa Developer
··5 min read

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:

  1. satisfies FeaturesWithFallbacks preserves exact literal types, so useFeature('theme-config') returns ComputedRef<{ mode: 'light' | 'dark', showLogo: boolean }> rather than a generic object.
  2. 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

  1. Install @supashiphq/vue-sdk and register createSupaship in main.ts
  2. Define your flags with satisfies FeaturesWithFallbacks and add the declare module augmentation
  3. Use useFeature / useFeatures in components
  4. Sync user context with useFeatureContext after login
  5. Add Router guards for route-level access control
  6. 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