v0.0.20: Remove debug logging, clean up custom ListView
@xtr-dev/payload-feature-flags
Feature flags plugin for Payload CMS v3. Manage feature toggles, A/B tests, and rollouts from your admin panel.
⚠️ Pre-release Warning: This package is currently in active development (v0.0.x). Breaking changes may occur before v1.0.0. Not recommended for production use.
Features
- 🚀 Easy setup - Add to your Payload config and you're done
- 🎛️ Admin dashboard - Manage flags from your Payload admin panel
- 🔄 Gradual rollouts - Roll out features to a percentage of users
- 🧪 A/B testing - Test different versions of features
- 🛣️ REST API - Check flag status via API endpoints
- 🗃️ Quick demo - Try it instantly with no database setup
Installation
npm install @xtr-dev/payload-feature-flags
# or with pnpm
pnpm add @xtr-dev/payload-feature-flags
# or with yarn
yarn add @xtr-dev/payload-feature-flags
Requirements
- Payload CMS v3.37.0+
- Node.js 18.20.2+ or 20.9.0+
- React 19.1.0+
Quick Start
Basic Setup
Add the plugin to your Payload config:
import { buildConfig } from 'payload'
import { payloadFeatureFlags } from '@xtr-dev/payload-feature-flags'
export default buildConfig({
// ... your existing config
plugins: [
payloadFeatureFlags({
// All options are optional - these are the defaults
defaultValue: false, // New flags start disabled
enableRollouts: true, // Allow percentage rollouts
enableVariants: true, // Allow A/B testing
disabled: false, // Plugin enabled
}),
],
})
Configuration Options
Available plugin options:
export type PayloadFeatureFlagsConfig = {
/**
* Default value for new feature flags
* @default false
*/
defaultValue?: boolean
/**
* Enable percentage-based rollouts
* @default true
*/
enableRollouts?: boolean
/**
* Enable variant/experiment support (A/B testing)
* @default true
*/
enableVariants?: boolean
/**
* Disable the plugin while keeping the database schema intact
* @default false
*/
disabled?: boolean
/**
* Override collection configuration
*/
collectionOverrides?: {
// Override any collection config
slug?: string // @default 'feature-flags'
access?: CollectionConfig['access']
admin?: CollectionConfig['admin']
hooks?: CollectionConfig['hooks']
timestamps?: CollectionConfig['timestamps']
versions?: CollectionConfig['versions']
// ... any other collection config
// Customize fields
fields?: (args: { defaultFields: Field[] }) => Field[]
}
}
Custom Fields and Access
Add custom fields or change permissions using collectionOverrides:
payloadFeatureFlags({
collectionOverrides: {
// Custom collection slug
slug: 'my-feature-flags',
// Custom access control
access: {
read: ({ req: { user } }) => !!user, // authenticated users only
update: ({ req: { user } }) => user?.role === 'admin', // admins only
},
// Add custom fields
fields: ({ defaultFields }) => [
...defaultFields,
{
name: 'environment',
type: 'select',
options: ['development', 'staging', 'production'],
required: true,
},
{
name: 'expiresAt',
type: 'date',
admin: {
description: 'Auto-disable this flag after this date',
},
},
],
// Disable versioning (enabled by default)
versions: false,
// Add hooks
hooks: {
beforeChange: [
async ({ data, req }) => {
// Add audit log, validation, etc.
console.log(`Flag ${data.name} changed by ${req.user?.email}`)
return data
},
],
},
},
})
Security Considerations
Collection Access Control: The feature flags collection uses Payload's standard access control system:
// Example: Secure collection access
access: {
// Option 1: Simple authentication check
read: ({ req: { user } }) => !!user, // Only authenticated users
// Option 2: More granular control
read: ({ req: { user } }) => {
if (!user) return false // No anonymous access
if (user.role === 'admin') return true // Admins see all flags
return { environment: { equals: 'public' } } // Others see public flags only
}
}
Production Security Best Practices:
For production environments, consider implementing these additional security measures:
// Example: API key authentication for external services
collectionOverrides: {
access: {
read: ({ req }) => {
// Check for API key in headers for service-to-service calls
const apiKey = req.headers['x-api-key']
if (apiKey && apiKey === process.env.FEATURE_FLAGS_API_KEY) {
return true
}
// Fall back to user authentication
return !!req.user
}
}
}
Rate Limiting: Use Payload's built-in rate limiting or implement middleware:
// Example with express-rate-limit
import rateLimit from 'express-rate-limit'
const featureFlagLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Limit each IP to 100 requests per windowMs
standardHeaders: true,
legacyHeaders: false,
})
// Apply to your API routes
app.use('/api/feature-flags', featureFlagLimiter)
Important: The plugin uses Payload's native REST API for the collection, which respects all access control rules.
Usage
Managing Feature Flags
Once installed, the plugin automatically:
- Creates a dedicated collection - A
feature-flagscollection (or custom name) for managing all flags - Provides a clean admin interface - Manage flags directly from the Payload admin panel
- Adds a custom dashboard view - Enhanced UI for managing flags at
/admin/feature-flags-overview - Exposes REST API endpoints - Simple endpoints for checking flag states
- Keeps your data clean - No modifications to your existing collections
Admin Interface
The plugin provides a custom admin view with enhanced UI for managing feature flags:
📍 Access: /admin/feature-flags-overview
Features:
- 📊 Dashboard Overview - Visual stats showing total, enabled, and rolling out flags
- 🔍 Search & Filter - Find flags by name/description and filter by status
- 🎛️ Quick Toggle - Enable/disable flags with visual toggle switches
- 🏷️ Smart Labels - Visual indicators for rollout percentages, A/B tests, and environments
- 📱 Responsive Design - Works seamlessly on desktop and mobile devices
The custom view provides a more user-friendly interface compared to the standard collection view, with:
- Real-time status indicators
- One-click flag toggling
- Better visual organization
- Advanced filtering capabilities
Using Feature Flags in React Server Components
The plugin provides server-side hooks for React Server Components:
import {
isFeatureEnabled,
getFeatureFlag,
isUserInRollout,
getUserVariant
} from '@xtr-dev/payload-feature-flags/rsc'
// Simple feature check
export default async function HomePage() {
const showNewDesign = await isFeatureEnabled('new-homepage-design')
return showNewDesign ? <NewHomePage /> : <LegacyHomePage />
}
// Percentage-based rollout
export default async function Dashboard({ userId }: { userId: string }) {
const inRollout = await isUserInRollout('beta-dashboard', userId)
return inRollout ? <BetaDashboard /> : <ClassicDashboard />
}
// A/B testing with variants
export default async function ProductPage({ userId }: { userId: string }) {
const variant = await getUserVariant('product-page-test', userId)
switch(variant) {
case 'layout-a':
return <ProductLayoutA />
case 'layout-b':
return <ProductLayoutB />
default:
return <DefaultProductLayout />
}
}
Using Feature Flags via REST API
The plugin uses Payload's native REST API for the collection. You can access feature flags through the standard Payload REST endpoints:
// Get all feature flags
const response = await fetch('/api/feature-flags')
const result = await response.json()
// result.docs contains the array of feature flags
// Query specific feature flags
const response = await fetch('/api/feature-flags?where[name][equals]=new-dashboard')
const result = await response.json()
if (result.docs.length > 0 && result.docs[0].enabled) {
// Show new dashboard
}
// Get only enabled flags
const response = await fetch('/api/feature-flags?where[enabled][equals]=true')
const result = await response.json()
Important Security Notes:
- API endpoints respect your collection access controls - they don't bypass security
- Configure access permissions using
collectionOverrides.access(see example above) - Anonymous users can only access flags if you explicitly allow it in access controls
API Endpoints
The plugin uses Payload's standard REST API endpoints for the feature-flags collection:
Get All Feature Flags
GET /api/feature-flags
Returns paginated feature flags:
{
"docs": [
{
"id": "...",
"name": "new-dashboard",
"enabled": true,
"rolloutPercentage": 50,
"variants": null,
"metadata": {},
"createdAt": "...",
"updatedAt": "..."
},
{
"id": "...",
"name": "beta-feature",
"enabled": true,
"rolloutPercentage": 100,
"variants": [
{ "name": "control", "weight": 50, "metadata": {} },
{ "name": "variant-a", "weight": 50, "metadata": {} }
],
"metadata": {},
"createdAt": "...",
"updatedAt": "..."
}
],
"totalDocs": 2,
"limit": 10,
"page": 1,
"totalPages": 1,
"pagingCounter": 1,
"hasPrevPage": false,
"hasNextPage": false
}
Query Specific Feature Flag
GET /api/feature-flags?where[name][equals]=new-dashboard
Returns matching feature flags:
{
"docs": [
{
"id": "...",
"name": "new-dashboard",
"enabled": true,
"rolloutPercentage": 50,
"variants": null,
"metadata": {},
"createdAt": "...",
"updatedAt": "..."
}
],
"totalDocs": 1,
"limit": 10,
"page": 1,
"totalPages": 1,
"pagingCounter": 1,
"hasPrevPage": false,
"hasNextPage": false
}
Feature Flag Schema
The plugin creates a collection with the following fields:
name(required, unique) - Unique identifier for the feature flagdescription- Description of what the flag controlsenabled(required) - Toggle the flag on/offrolloutPercentage- Percentage of users (0-100) who see this featurevariants- Array of variants for A/B testingname- Variant identifierweight- Distribution weight (all weights should sum to 100)metadata- Additional variant data
tags- Array of tags for organizationmetadata- JSON field for additional flag data
Advanced Usage
Conditional Feature Rendering
// Example: Check feature flag from your frontend
async function checkFeature(flagName: string): Promise<boolean> {
try {
const response = await fetch(`/api/feature-flags/${flagName}`)
if (!response.ok) return false
const flag = await response.json()
return flag.enabled
} catch {
return false // Default to disabled on error
}
}
// Usage in your application
if (await checkFeature('new-dashboard')) {
// Show new dashboard
} else {
// Show legacy dashboard
}
Implementing Gradual Rollouts
// Example: Hash-based rollout
function isUserInRollout(userId: string, percentage: number): boolean {
// Simple hash function for consistent user bucketing
const hash = userId.split('').reduce((acc, char) => {
return ((acc << 5) - acc) + char.charCodeAt(0)
}, 0)
return (Math.abs(hash) % 100) < percentage
}
// Check if user should see the feature
const flag = await fetch('/api/feature-flags/new-feature').then(r => r.json())
if (flag.enabled && isUserInRollout(userId, flag.rolloutPercentage)) {
// Show feature to this user
}
A/B Testing with Variants
// Example: Select variant based on user
function selectVariant(userId: string, variants: Array<{name: string, weight: number}>) {
const hash = Math.abs(userId.split('').reduce((acc, char) => {
return ((acc << 5) - acc) + char.charCodeAt(0)
}, 0))
const bucket = hash % 100
let cumulative = 0
for (const variant of variants) {
cumulative += variant.weight
if (bucket < cumulative) {
return variant.name
}
}
return variants[0]?.name || 'control'
}
// Usage
const flag = await fetch('/api/feature-flags/homepage-test').then(r => r.json())
if (flag.enabled && flag.variants) {
const variant = selectVariant(userId, flag.variants)
// Render based on variant
}
Performance Considerations
Client-Side Caching
For improved performance, consider implementing client-side caching when fetching feature flags:
// Example: Simple cache with TTL
class FeatureFlagCache {
private cache = new Map<string, { data: any; expiry: number }>()
private ttl = 5 * 60 * 1000 // 5 minutes
async get(key: string, fetcher: () => Promise<any>) {
const cached = this.cache.get(key)
if (cached && cached.expiry > Date.now()) {
return cached.data
}
const data = await fetcher()
this.cache.set(key, { data, expiry: Date.now() + this.ttl })
return data
}
}
const flagCache = new FeatureFlagCache()
// Use with the hooks
const flags = await flagCache.get('all-flags', () =>
fetch('/api/feature-flags?limit=1000').then(r => r.json())
)
Migration
Disabling the Plugin
If you need to temporarily disable the plugin (e.g., during migrations), set disabled: true in the configuration. This keeps the database schema intact while disabling plugin functionality.
payloadFeatureFlags({
disabled: true, // Plugin functionality disabled, schema preserved
})
Development
Try the Demo
Test the plugin with zero setup:
git clone https://github.com/xtr-dev/payload-feature-flags
cd payload-feature-flags
pnpm install
pnpm dev
# Visit http://localhost:3000
What you get:
- 🗃️ No database needed - Uses in-memory MongoDB
- 🎯 Sample data included - Ready-to-test feature flag
- 🔑 Auto-login - Use
dev@payloadcms.com / test - 📱 Working dashboard - See flags in action
Building the Plugin
# Install dependencies
pnpm install
# Build the plugin
pnpm build
# Run tests
pnpm test
# Run linting
pnpm lint
Development Mode
# Start development server with hot reload
pnpm dev
# Generate types
pnpm generate:types
# Generate import map
pnpm generate:importmap
API Reference
Main Plugin Export
import { payloadFeatureFlags } from '@xtr-dev/payload-feature-flags'
payloadFeatureFlags: Main plugin configuration functionPayloadFeatureFlagsConfig: TypeScript type for configuration options
Server Component Hooks (RSC Export)
import {
getFeatureFlag,
isFeatureEnabled,
getAllFeatureFlags,
isUserInRollout,
getUserVariant,
getFeatureFlagsByTag
} from '@xtr-dev/payload-feature-flags/rsc'
Available Functions:
getFeatureFlag(flagName: string)- Get complete flag dataisFeatureEnabled(flagName: string)- Simple boolean checkgetAllFeatureFlags()- Get all active flagsisUserInRollout(flagName: string, userId: string)- Check rollout percentagegetUserVariant(flagName: string, userId: string)- Get A/B test variantgetFeatureFlagsByTag(tag: string)- Get flags by tag
Troubleshooting
Common Issues
Plugin not loading:
- Ensure Payload CMS v3.37.0+ is installed
- Check that the plugin is properly added to the
pluginsarray in your Payload config
Feature flags not appearing:
- Verify that collections are specified in the plugin configuration
- Check that the plugin is not disabled (
disabled: false)
TypeScript errors:
- Ensure all peer dependencies are installed
- Run
pnpm generate:typesto regenerate type definitions
Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'Add some amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
License
This project is licensed under the MIT License - see the LICENSE file for details.
Support
For issues, questions, or suggestions, please open an issue on GitHub.
Changelog
See CHANGELOG.md for a list of changes.
Authors
- XTR Development Team
Acknowledgments
- Built for Payload CMS
- Inspired by modern feature flag management systems