Bas van den Aakster 98cab95411 Implement proper Payload CMS DefaultTemplate layout integration
Layout Implementation:
- Fixed import to use DefaultTemplate from '@payloadcms/next/templates'
- Added proper template props interface with i18n, locale, payload, etc.
- Restructured component to use DefaultTemplate wrapper correctly
- Created FeatureFlagsContent as child component for template

Template Structure:
- Component now receives standard Payload admin view props
- DefaultTemplate provides proper admin layout with sidebar navigation
- All template props (i18n, locale, params, payload, permissions, etc.) are passed through
- Maintains theme integration and responsive design within admin layout

The feature flags dashboard now properly integrates with Payload's admin
layout system, including the navigation sidebar and standard admin styling,
while preserving all spreadsheet functionality and inline editing capabilities.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-03 16:16:42 +02:00
2025-09-28 18:18:40 +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 602 KiB
Languages
TypeScript 94.3%
JavaScript 5.7%