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.
const 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
// 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: trueWhen 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.
// 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.
<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.
// 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