Bas be24beeaa5 Merge pull request #16 from xtr-dev/dev
v0.0.20: Remove debug logging, clean up custom ListView
2025-10-03 21:18:02 +02:00
2025-09-28 18:18:40 +02:00
2025-10-03 20:10:10 +02:00

@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:

  1. Creates a dedicated collection - A feature-flags collection (or custom name) for managing all flags
  2. Provides a clean admin interface - Manage flags directly from the Payload admin panel
  3. Adds a custom dashboard view - Enhanced UI for managing flags at /admin/feature-flags-overview
  4. Exposes REST API endpoints - Simple endpoints for checking flag states
  5. 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 flag
  • description - Description of what the flag controls
  • enabled (required) - Toggle the flag on/off
  • rolloutPercentage - Percentage of users (0-100) who see this feature
  • variants - Array of variants for A/B testing
    • name - Variant identifier
    • weight - Distribution weight (all weights should sum to 100)
    • metadata - Additional variant data
  • tags - Array of tags for organization
  • metadata - 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 function
  • PayloadFeatureFlagsConfig: 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 data
  • isFeatureEnabled(flagName: string) - Simple boolean check
  • getAllFeatureFlags() - Get all active flags
  • isUserInRollout(flagName: string, userId: string) - Check rollout percentage
  • getUserVariant(flagName: string, userId: string) - Get A/B test variant
  • getFeatureFlagsByTag(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 plugins array 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:types to regenerate type definitions

Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

  1. Fork the repository
  2. Create your feature branch (git checkout -b feature/amazing-feature)
  3. Commit your changes (git commit -m 'Add some amazing feature')
  4. Push to the branch (git push origin feature/amazing-feature)
  5. 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
Description
No description provided
Readme 600 KiB
Languages
TypeScript 94.3%
JavaScript 5.7%