Feature Flags

Ship a feature switched off, turn it on without a deploy, and delete the flag once the question it asked has an answer.

Syntaxconst flags = useFlags(); flags.isEnabled('new-checkout')

A feature flag is a value your code reads to decide whether a feature runs. It lets you merge and deploy unfinished work with the feature off, then turn it on as a separate act. Deploying and releasing stop being the same event.

Two kinds

A build-time flag is fixed when the bundle is built, from a VITE_-prefixed environment variable read through import.meta.env, or from define in vite.config.ts. Vite replaces the expression with a literal, so minification can delete the branch that is off and the code never ships. Changing it needs a rebuild.

A runtime flag is fetched when the application starts. You can change it without a deploy, which is the point, but both code paths ship and the fetch is one more thing that can fail.

Reading them

A Pinia store is a natural home: load once at startup and expose isEnabled(name). Read the flag at the edge of the feature — one v-if, one route, one branch in one function — not in fifteen components.

Removing them

A flag is a question. When it has an answer, delete the flag and the losing branch in a commit that does nothing else. Flags that outlive their question multiply the states the application can be in: ten of them make 1024 combinations, and you have tested two.

Example

Example · vue
// src/stores/flags.ts
import { ref } from 'vue'
import { defineStore } from 'pinia'

type Flags = Record<string, boolean>

export const useFlags = defineStore('flags', () => {
  const values = ref<Flags>({})
  const loaded = ref(false)

  async function load(): Promise<void> {
    const response = await fetch('/api/flags', { credentials: 'include' })
    values.value = response.ok ? ((await response.json()) as Flags) : {}
    loaded.value = true
  }

  function isEnabled(name: string): boolean {
    const override = new URLSearchParams(window.location.search).get(`ff_${name}`)
    if (override !== null) return override !== '0'
    return values.value[name] === true
  }

  return { values, loaded, load, isEnabled }
})

// With values.value = { 'new-checkout': true } and no query string:
// isEnabled('new-checkout')  // Output: true
// isEnabled('legacy-import') // Output: false
// With ?ff_legacy-import=1 in the URL:
// isEnabled('legacy-import') // Output: true

When to use it

  • Merging a half-finished redesign instead of keeping a branch open for three weeks.
  • Turning a feature on for staff, then for everyone, without building anything again.
  • Switching one broken feature off at night without rolling back the whole release.
  • Running the old and new checkout side by side to compare completion rates.

More examples

Loading flags before the first paint

`useFlags()` can only run after a Pinia instance is installed, so the order of these lines matters. Mounting inside `finally` means a flag service that is down or slow still produces a rendered application with every flag off, which is the safe default. If you would rather render immediately, mount first and let `loaded` gate the flagged part of the screen instead.

Example · ts
// src/main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import { useFlags } from './stores/flags'

const app = createApp(App)
app.use(createPinia())
app.use(router)

const flags = useFlags()
flags.load().finally(() => {
  app.mount('#app')
})

One decision point in the template

The flag is read once, in the one place the two implementations meet. `v-if` is right here rather than `v-show`: the branch that is off is never created, so it issues no requests and runs no lifecycle hooks. Both components are static imports, so both are in the bundle; load the new path with `defineAsyncComponent` or a dynamic `import()` if you want it out of the main chunk until the flag is on.

Example · vue
<script setup lang="ts">
import CheckoutV1 from '@/components/CheckoutV1.vue'
import CheckoutV2 from '@/components/CheckoutV2.vue'
import { useFlags } from '@/stores/flags'

const flags = useFlags()
</script>

<template>
  <checkout-v2 v-if="flags.isEnabled('new-checkout')" />
  <checkout-v1 v-else />
</template>

<style scoped>
:global(body) {
  margin: 0;
}
</style>

Hiding a whole route behind a flag

Putting the flag name in a route's `meta` keeps one guard for any number of flagged routes. `RouteMeta` is typed so unknown keys come back as `unknown`, which is why the guard checks `typeof flag === 'string'` before using it; you can declare the key instead by augmenting the `RouteMeta` interface. A guard that returns a route location redirects, and returning nothing lets the navigation continue — the `next` callback of older examples is still accepted but no longer the documented form.

Example · ts
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import { useFlags } from '@/stores/flags'

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    { path: '/', name: 'home', component: () => import('@/views/HomeView.vue') },
    {
      path: '/reports',
      name: 'reports',
      component: () => import('@/views/ReportsView.vue'),
      meta: { flag: 'reports-beta' },
    },
  ],
})

router.beforeEach((to) => {
  const flag = to.meta.flag
  if (typeof flag === 'string' && !useFlags().isEnabled(flag)) {
    return { name: 'home' }
  }
})

export default router

Discussion

  • Be the first to comment on this lesson.