@kilivi/payloadcms-theme-management
v2.0.0
Published
Theme configuration plugin for Payload CMS v3 with 60+ professional themes and standalone collection support
Downloads
490
Maintainers
Readme
Theme Management Plugin for Payload CMS v3
A comprehensive theme management plugin for Payload CMS v3 that provides powerful theming capabilities with SSR support, preventing FOUC (Flash of Unstyled Content).
🎉 Version 1.0.2 - Production Ready!
- ✅ Standalone Global Support - Create separate appearance settings as a global
- ✅ Auto-Populate Light/Dark Colors - Theme selection hook automatically fills color fields
- ✅ Multi-Tenant Ready - Full support for multi-tenant applications with isolated themes
- ✅ Cache Invalidation - Automatic cache invalidation after saving appearance settings
- ✅ Live Theme Preview - Real-time theme preview in admin panel at
/admin/theme-preview - ✅ Professional Color Picker - Enhanced color picker with react-colorful library
- ✅ shadcn/ui Compatible - Works with https://ui.shadcn.com/themes
- ✅ TweakCN Compatible - Works with https://tweakcn.com/editor/theme
Features
- 🎨 Multiple Theme Presets - Cool, Brutal, Neon, Solar, and more
- 🎯 Auto-Populate Colors - Theme selection automatically sets Light/Dark mode colors
- 🌍 Standalone Global - Create separate appearance settings global (v0.6.0+)
- 🖼️ Live Theme Preview - Real-time preview in admin panel (no configuration needed!)
- 🎨 Extended Themes - OKLCH-based themes with full shadcn/ui token support
- 🎨 Custom Color Palette - Full HSL color customization (19 semantic tokens)
- 🔤 Typography Control - Google Fonts integration for headings, body, and code
- 📐 Border Radius Presets - Sharp, Rounded, or Pill styles
- 🌓 Dark Mode Support - Built-in light/dark/system mode toggle
- 📊 Chart Colors - Data visualization color palette
- 🚀 SSR Theme Injection - Zero FOUC with server-side rendering
- ⚡ Performance Optimized - Critical CSS inlining, preload links
- 🎯 Type Safe - Full TypeScript support
- 🔧 Highly Configurable - Flexible plugin options
- 👥 Multi-Tenant Support - Built-in multi-tenant capabilities
Installation
pnpm add @kilivi/payloadcms-theme-management
# or
npm install @kilivi/payloadcms-theme-management
# or
yarn add @kilivi/payloadcms-theme-managementQuick Start
1. Add Plugin to Payload Config
Option A: Add as Tab to Existing Collection (Default)
import { themeManagementPlugin } from '@kilivi/payloadcms-theme-management'
import { buildConfig } from 'payload'
export default buildConfig({
// ... other config
collections: [
{
slug: 'site-settings',
fields: [
{
name: 'siteName',
type: 'text',
},
// Theme configuration will be injected here as a tab inside this collection
],
},
],
plugins: [
themeManagementPlugin({
enabled: true,
targetCollection: 'site-settings',
defaultTheme: 'cool',
includeColorModeToggle: true,
enableLogging: true,
}),
],
})Note: Option A injects an Appearance Settings tab into the specified collection (
targetCollection). If you prefer a separate global (standalone settings), use Option B below and setuseStandaloneCollection: truein the plugin options.
Option B: Create Standalone Global (Separate Settings)
import { themeManagementPlugin } from '@kilivi/payloadcms-theme-management'
import { buildConfig } from 'payload'
export default buildConfig({
// ... other config
plugins: [
themeManagementPlugin({
enabled: true,
useStandaloneCollection: true, // Creates a separate global
standaloneCollectionSlug: 'appearance-settings', // Optional: custom slug
standaloneCollectionLabel: 'Appearance Settings', // Optional: custom label
defaultTheme: 'cool',
includeColorModeToggle: true,
enableLogging: true,
}),
],
})2. Add Server Theme Injection (Next.js)
⚠️ IMPORTANT: Import from /server entry point!
If Using Default (Tab in existing collection):
// app/layout.tsx
import { ServerThemeInjector } from '@kilivi/payloadcms-theme-management/server'
import configPromise from '@payload-config'
import { getPayload } from 'payload'
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const payload = await getPayload({ config: configPromise })
const { docs } = await payload.find({
collection: 'site-settings', // Your target collection
limit: 1,
})
return (
<html lang="en">
<head>
<ServerThemeInjector themeConfiguration={docs[0]?.themeConfiguration} />
</head>
<body>{children}</body>
</html>
)
}If Using Standalone Global:
// app/layout.tsx
import { ServerThemeInjector } from '@kilivi/payloadcms-theme-management/server'
import configPromise from '@payload-config'
import { getPayload } from 'payload'
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const payload = await getPayload({ config: configPromise })
// Fetch from standalone global
const appearanceSettings = await payload.findGlobal({
slug: 'appearance-settings', // Your standalone global slug
})
return (
<html lang="en">
<head>
<ServerThemeInjector themeConfiguration={appearanceSettings?.themeConfiguration} />
</head>
<body>{children}</body>
</html>
)
}Cache Optimization with Next.js (Out of the Box)
When using standalone global, the plugin automatically invalidates cache after saving appearance settings using the tag global_appearance-settings (or global_{your-slug} if you use a custom slug).
You can optimize your theme fetching by using the built-in server helper:
// app/layout.tsx
import {
createCachedThemeFetcher,
ServerThemeInjector,
} from '@kilivi/payloadcms-theme-management/server'
import configPromise from '@payload-config'
import { getPayload } from 'payload'
const getCachedTheme = createCachedThemeFetcher({
globalSlug: 'appearance-settings',
revalidate: 3600,
loadAppearanceSettings: async () => {
const payload = await getPayload({ config: configPromise })
return payload.findGlobal({
slug: 'appearance-settings',
})
},
})
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const themeConfiguration = await getCachedTheme()
return (
<html lang="en">
<head>
<ServerThemeInjector themeConfiguration={themeConfiguration} />
</head>
<body>{children}</body>
</html>
)
}Benefits:
- ⚡ Theme settings are cached and reused across requests
- 🔄 Cache automatically invalidates when you save appearance settings in admin
- 🚀 Reduces database queries and improves performance
Cache Tag Format:
- Default:
global_appearance-settings - Custom slug:
global_{standaloneCollectionSlug}
Injected Revalidation Route
The plugin now injects a Payload endpoint (default: POST /api/theme/revalidate) that triggers the same tag/path invalidation logic used by live preview saves.
themeManagementPlugin({
useStandaloneCollection: true,
cacheRevalidation: {
routePath: '/theme/revalidate',
secret: process.env.THEME_REVALIDATE_SECRET,
tags: ['tenant:default'],
paths: ['/'],
},
})Set cacheRevalidation.injectRoute to false if you want to keep only automatic hook-based invalidation.
Note: The cache invalidation hook is only available for standalone global mode. When using the tab mode (default), implement your own caching strategy based on your collection structure.
3. Use Theme Variables in Your CSS
The plugin injects CSS custom properties you can use:
.my-component {
background-color: hsl(var(--background));
color: hsl(var(--foreground));
border-radius: var(--radius);
font-family: var(--font-body);
}Or with Tailwind CSS:
<div className="bg-primary text-primary-foreground rounded-lg">Themed Content</div>Plugin Configuration Options
| Option | Type | Default | Description |
| --------------------------- | ------------------------------------- | ----------------------- | -------------------------------------------------------------------- |
| enabled | boolean | true | Enable/disable the plugin |
| targetCollection | string | 'site-settings' | Collection slug to add theme field to (when not using standalone) |
| useStandaloneCollection | boolean | false | Create a separate global instead of adding as a tab |
| standaloneCollectionSlug | string | 'appearance-settings' | Slug for the standalone global |
| standaloneCollectionLabel | string \| Record<string, string> | 'Appearance Settings' | Label for the standalone global (supports i18n) |
| themePresets | ThemePreset[] | Built-in presets | Custom theme presets |
| defaultTheme | string | 'cool' | Default theme preset name |
| includeColorModeToggle | boolean | true | Show light/dark mode toggle |
| includeCustomCSS | boolean | true | Allow custom CSS injection |
| includeBrandIdentity | boolean | false | Show brand identity fields |
| enableAdvancedFeatures | boolean | true | Enable advanced customization |
| enableLogging | boolean | false | Log plugin actions to console (includes cache invalidation logs) |
| livePreview | boolean \| LivePreviewOptions | true | Enable/administer live preview URL resolution (home → fallback) |
| cacheRevalidation | boolean \| CacheRevalidationOptions | standalone: true | Configure injected route + tags/paths for Next.js cache invalidation |
Cache Invalidation Note: In standalone mode, the plugin revalidates global_{standaloneCollectionSlug} automatically and injects POST /api/theme/revalidate by default. Pair it with createCachedThemeFetcher from @kilivi/payloadcms-theme-management/server for a zero-boilerplate setup.
Available Theme Presets
- Cool - Professional blue theme
- Brutal - High contrast, bold design
- Neon - Vibrant, energetic colors
- Solar - Warm, golden tones
- Dealership - Automotive-inspired
- Real Estate - Professional property theme (+ Gold, Neutral variants)
Live Theme Preview
The plugin automatically adds a Live Preview page to your admin panel at /admin/theme-preview.
Features:
- ✅ Zero Configuration - Works automatically when plugin is installed
- ✅ Real-time Updates - See changes instantly as you edit theme settings
- ✅ Light/Dark Toggle - Preview both modes side-by-side
- ✅ Component Showcase - View cards, buttons, inputs, badges, and more
- ✅ Professional UI - Clean, modern preview interface
How to Access:
- Install the plugin (see Quick Start above)
- Navigate to
/admin/theme-previewin your Payload admin panel - Open theme settings in another tab and see changes update live!
The preview automatically watches your themeConfiguration field and displays real-time updates without any additional setup.
Translations / i18n
Extend or override translations at runtime with registerTranslations. The plugin merges your translations with English defaults, so missing keys fall back to English. See docs/TRANSLATIONS.md for details and examples.
API Reference
Main Entry Point
Import from @kilivi/payloadcms-theme-management for client-safe code:
// Plugin
// Client Components
// Utilities
import {
fetchThemeConfiguration,
generateThemeColorsCss,
generateThemeCSS,
getThemeStyles,
resolveThemeConfiguration,
themeManagementPlugin,
ThemeProvider,
} from '@kilivi/payloadcms-theme-management'
// Types
import type {
ThemeDefaults,
ThemeManagementPluginOptions,
ThemePreset,
} from '@kilivi/payloadcms-theme-management'Server Entry Point
⚠️ Import from /server for server components only:
import {
getThemeCriticalCSS,
getThemeCSS,
ServerThemeInjector,
} from '@kilivi/payloadcms-theme-management/server'Heads up: Legacy helpers
getThemeCSSPathandgenerateThemePreloadLinksare still exported for backwards compatibility but now return empty strings and emit console warnings. Prefer the inline CSS utilities above.
Subpath Exports
// Direct field imports
// Direct component imports
import { ThemePreview } from '@kilivi/payloadcms-theme-management/components/ThemePreview'
import { ThemeColorPickerField } from '@kilivi/payloadcms-theme-management/fields/ThemeColorPickerField'
import { ThemeTokenSelectField } from '@kilivi/payloadcms-theme-management/fields/ThemeTokenSelectField'Integration Examples
With Tailwind CSS
tailwind.config.mjs
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable @typescript-eslint/no-require-imports */
/* eslint-disable import/no-anonymous-default-export */
import defaultTheme from 'tailwindcss/defaultTheme'
/** @type {import('tailwindcss').Config} */
export default {
darkMode: ['class'],
content: [
'./src/app/**/*.{ts,tsx}',
'./src/components/**/*.{ts,tsx}',
'./src/collections/**/*.{ts,tsx}',
'./src/providers/**/*.{ts,tsx}',
'./src/heros/**/*.{ts,tsx}',
],
safelist: [
// Grid columns
'col-span-4',
'md:col-span-2',
'lg:col-span-4',
'lg:col-span-6',
'lg:col-span-8',
'lg:col-span-12',
// Border colors
'border-border',
'border-error',
'border-success',
'border-warning',
// Background colors
'bg-card',
'bg-error/30',
'bg-success/30',
'bg-warning/30',
],
theme: {
container: {
center: true,
padding: {
DEFAULT: 'var(--layout-containerPadding)',
md: 'var(--layout-containerPaddingTablet)',
lg: 'var(--layout-containerPaddingDesktop)',
},
screens: {
sm: '640px',
md: '768px',
lg: '1024px',
xl: '1280px',
},
},
extend: {
colors: {
background: 'var(--background)',
foreground: 'var(--foreground)',
card: 'var(--card)',
'card-foreground': 'var(--card-foreground)',
popover: 'var(--popover)',
'popover-foreground': 'var(--popover-foreground)',
primary: 'var(--primary)',
'primary-foreground': 'var(--primary-foreground)',
secondary: 'var(--secondary)',
'secondary-foreground': 'var(--secondary-foreground)',
muted: 'var(--muted)',
'muted-foreground': 'var(--muted-foreground)',
accent: 'var(--accent)',
'accent-foreground': 'var(--accent-foreground)',
destructive: 'var(--destructive)',
'destructive-foreground': 'var(--destructive-foreground)',
border: 'var(--border)',
input: 'var(--input)',
ring: 'var(--ring)',
success: 'var(--success)',
'success-foreground': 'var(--success-foreground)',
warning: 'var(--warning)',
'warning-foreground': 'var(--warning-foreground)',
error: 'var(--error)',
'error-foreground': 'var(--error-foreground)',
info: 'var(--info)',
'info-foreground': 'var(--info-foreground)',
},
borderRadius: {
sm: 'var(--radius-small)',
DEFAULT: 'var(--radius-default)',
md: 'var(--radius-medium)',
lg: 'var(--radius-large)',
},
fontFamily: {
sans: ['var(--typography-fontFamily)', ...defaultTheme.fontFamily.sans],
heading: ['var(--typography-headingFamily)', ...defaultTheme.fontFamily.sans],
mono: ['var(--font-geist-mono)', ...defaultTheme.fontFamily.mono],
},
fontSize: {
base: ['var(--typography-baseFontSize)', { lineHeight: 'var(--typography-lineHeight)' }],
},
letterSpacing: {
tight: 'var(--typography-letterSpacing-tight)',
normal: 'var(--typography-letterSpacing-normal)',
wide: 'var(--typography-letterSpacing-wide)',
},
fontWeight: {
normal: 'var(--typography-fontWeights-normal)',
medium: 'var(--typography-fontWeights-medium)',
semibold: 'var(--typography-fontWeights-semibold)',
bold: 'var(--typography-fontWeights-bold)',
},
spacing: {
section: {
DEFAULT: 'var(--layout-sectionSpacing)',
md: 'var(--layout-sectionSpacingTablet)',
lg: 'var(--layout-sectionSpacingDesktop)',
},
},
transitionProperty: {
button: 'var(--components-button-transition)',
},
scale: {
'button-hover': 'var(--components-button-hover-scale)',
},
opacity: {
'button-hover': 'var(--components-button-hover-opacity)',
},
boxShadow: {
card: 'var(--components-card-shadow)',
},
padding: {
card: 'var(--components-card-padding)',
button: 'var(--components-button-padding)',
input: 'var(--components-input-padding)',
},
height: {
input: 'var(--components-input-height)',
},
typography: ({ theme }) => ({
DEFAULT: {
css: {
'--tw-prose-body': theme('colors.foreground'),
'--tw-prose-headings': theme('colors.foreground'),
'--tw-prose-lead': theme('colors.muted.foreground'),
'--tw-prose-links': theme('colors.primary'),
'--tw-prose-bold': theme('colors.foreground'),
'--tw-prose-counters': theme('colors.foreground'),
'--tw-prose-bullets': theme('colors.foreground'),
'--tw-prose-hr': theme('colors.border'),
'--tw-prose-quotes': theme('colors.foreground'),
'--tw-prose-quote-borders': theme('colors.border'),
'--tw-prose-captions': theme('colors.muted.foreground'),
'--tw-prose-code': theme('colors.foreground'),
'--tw-prose-pre-code': theme('colors.foreground'),
'--tw-prose-pre-bg': theme('colors.muted'),
'--tw-prose-th-borders': theme('colors.border'),
'--tw-prose-td-borders': theme('colors.border'),
h1: {
fontWeight: 'var(--typography-fontWeights-bold)',
marginBottom: '0.25em',
fontFamily: theme('fontFamily.heading'),
letterSpacing: 'var(--typography-letterSpacing-tight)',
},
h2: {
fontFamily: theme('fontFamily.heading'),
fontWeight: 'var(--typography-fontWeights-semibold)',
letterSpacing: 'var(--typography-letterSpacing-tight)',
},
h3: {
fontFamily: theme('fontFamily.heading'),
fontWeight: 'var(--typography-fontWeights-semibold)',
letterSpacing: 'var(--typography-letterSpacing-tight)',
},
h4: {
fontFamily: theme('fontFamily.heading'),
fontWeight: 'var(--typography-fontWeights-medium)',
letterSpacing: 'var(--typography-letterSpacing-normal)',
},
},
},
base: {
css: [
{
h1: { fontSize: '2.5rem' },
h2: { fontSize: '1.25rem' },
},
],
},
md: {
css: [
{
h1: { fontSize: '3.5rem' },
h2: { fontSize: '1.5rem' },
},
],
},
invert: {
css: {
'--tw-prose-body': 'var(--foreground)',
'--tw-prose-headings': 'var(--foreground)',
'--tw-prose-lead': 'var(--muted-foreground)',
'--tw-prose-links': 'var(--primary)',
'--tw-prose-bold': 'var(--foreground)',
'--tw-prose-counters': 'var(--foreground)',
'--tw-prose-bullets': 'var(--foreground)',
'--tw-prose-hr': 'var(--border)',
'--tw-prose-quotes': 'var(--foreground)',
'--tw-prose-quote-borders': 'var(--border)',
'--tw-prose-captions': 'var(--muted-foreground)',
'--tw-prose-code': 'var(--foreground)',
'--tw-prose-pre-code': 'var(--foreground)',
'--tw-prose-pre-bg': 'var(--muted)',
'--tw-prose-th-borders': 'var(--border)',
'--tw-prose-td-borders': 'var(--border)',
},
},
}),
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
},
keyframes: {
'accordion-down': {
from: { height: 0 },
to: { height: 'var(--radix-accordion-content-height)' },
},
'accordion-up': {
from: { height: 'var(--radix-accordion-content-height)' },
to: { height: 0 },
},
},
},
},
plugins: [require('tailwindcss-animate'), require('@tailwindcss/typography')],
}globals.css
@import 'tailwindcss';
@config '../../../../../tailwind.config.mjs';
@layer base {
/* Theme Configuration CSS Variables */
:root {
/* Font Scale Presets */
--font-scale-small: 0.875;
--font-scale-medium: 1;
--font-scale-large: 1.125;
--font-scale-xl: 1.25;
/* Spacing Presets */
--spacing-compact: 0.75;
--spacing-comfortable: 1;
--spacing-spacious: 1.25;
--spacing-xl: 1.5;
/* Animation Levels */
--animation-none-duration: 0ms;
--animation-reduced-duration: 100ms;
--animation-normal-duration: 200ms;
--animation-enhanced-duration: 300ms;
/* Dynamic theme configuration (set by JS) */
--theme-font-scale: var(--font-scale-medium);
--theme-spacing: var(--spacing-comfortable);
--theme-animation-level: normal;
/* Apply spacing multiplier to layout */
--layout-containerPadding-scaled: calc(
var(--layout-containerPadding) * var(--theme-spacing, 1)
);
--layout-containerPaddingTablet-scaled: calc(
var(--layout-containerPaddingTablet) * var(--theme-spacing, 1)
);
--layout-containerPaddingDesktop-scaled: calc(
var(--layout-containerPaddingDesktop) * var(--theme-spacing, 1)
);
--layout-sectionSpacing-scaled: calc(var(--layout-sectionSpacing) * var(--theme-spacing, 1));
--layout-sectionSpacingTablet-scaled: calc(
var(--layout-sectionSpacingTablet) * var(--theme-spacing, 1)
);
--layout-sectionSpacingDesktop-scaled: calc(
var(--layout-sectionSpacingDesktop) * var(--theme-spacing, 1)
);
--components-card-padding-scaled: calc(
var(--components-card-padding) * var(--theme-spacing, 1)
);
--components-button-padding-scaled: var(--components-button-padding);
--components-input-padding-scaled: var(--components-input-padding);
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
@apply border-border;
}
html {
-webkit-text-size-adjust: 100%;
font-size: calc(var(--typography-baseFontSize, 16px) * var(--theme-font-scale, 1));
line-height: var(--typography-lineHeight, 1.5);
font-family: var(--typography-fontFamily, system-ui, sans-serif);
visibility: hidden;
}
/* Animation level controls */
[data-animation-level='none'] * {
animation-duration: var(--animation-none-duration) !important;
transition-duration: var(--animation-none-duration) !important;
}
[data-animation-level='reduced'] * {
animation-duration: var(--animation-reduced-duration) !important;
transition-duration: var(--animation-reduced-duration) !important;
}
[data-animation-level='normal'] * {
animation-duration: var(--animation-normal-duration);
transition-duration: var(--animation-normal-duration);
}
[data-animation-level='enhanced'] * {
animation-duration: var(--animation-enhanced-duration);
transition-duration: var(--animation-enhanced-duration);
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: var(
--typography-headingFamily,
var(--typography-fontFamily, system-ui, sans-serif)
);
font-weight: var(--typography-fontWeights-bold, 700);
line-height: 1.2;
}
/* Prevent flash of unstyled content except for Storybook */
:root:not([data-theme]):not([data-theme-mode]):not(.sb-show-main) {
visibility: hidden;
}
:root[data-theme][data-theme-mode],
.sb-show-main {
visibility: visible;
}
html:not(.sb-show-main) {
opacity: 0;
}
html[data-theme-mode='dark'],
html[data-theme-mode='light'],
html.sb-show-main {
opacity: 1;
transition: opacity 0ms;
}
/* Force immediate opacity when JS is disabled */
html:not([data-theme-mode]) {
opacity: 1;
}
/* Media queries for responsive layout */
@media (min-width: 640px) {
:root {
--layout-containerPadding: var(--layout-containerPaddingTablet, 2rem);
--layout-sectionSpacing: var(--layout-sectionSpacingTablet, 3rem);
}
}
@media (min-width: 1024px) {
:root {
--layout-containerPadding: var(--layout-containerPaddingDesktop, 2rem);
--layout-sectionSpacing: var(--layout-sectionSpacingDesktop, 4rem);
}
}
/* Ensure dark mode styles take precedence */
html[data-theme-mode='dark'] {
color-scheme: dark;
}
/* Native View Transitions API - React 19 */
@view-transition {
navigation: auto;
}
/* Smooth fade transition for page navigation */
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 0.3s;
animation-timing-function: ease-in-out;
}
::view-transition-old(root) {
animation-name: fade-out;
}
::view-transition-new(root) {
animation-name: fade-in;
}
@keyframes fade-out {
to {
opacity: 0;
}
}
@keyframes fade-in {
from {
opacity: 0;
}
}
/* Respect user's reduced motion preference */
@media (prefers-reduced-motion: reduce) {
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 0.01ms !important;
}
}
}Fetching Theme Configuration
The recommended way to fetch theme configuration is using Payload's native API with Next.js caching:
import configPromise from '@payload-config'
import { getPayload } from 'payload'
import { unstable_cache } from 'next/cache'
// Cached theme fetcher with automatic invalidation
const getCachedTheme = unstable_cache(
async () => {
const payload = await getPayload({ config: configPromise })
const global = await payload.findGlobal({
slug: 'appearance-settings', // or 'your-global-slug'
})
return global?.themeConfiguration
},
['appearance-settings'],
{
tags: ['global_appearance-settings'],
revalidate: 3600, // Revalidate every hour
},
)
// Use in your layout
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const themeConfiguration = await getCachedTheme()
// Use themeConfiguration...
}Legacy Helper (still available):
If you need the helper function, fetchThemeConfiguration is still exported:
import { fetchThemeConfiguration } from '@kilivi/payloadcms-theme-management'
// Simple fetch without caching
const theme = await fetchThemeConfiguration({
collectionSlug: 'appearance-settings',
useGlobal: true,
})However, prefer the native Payload API + unstable_cache approach for better performance and automatic invalidation support.
Custom Theme Preset
import type { ThemePreset } from '@kilivi/payloadcms-theme-management'
const myTheme: ThemePreset = {
name: 'my-custom-theme',
label: 'My Custom Theme',
colors: {
primary: { h: 220, s: 70, l: 50 },
secondary: { h: 180, s: 60, l: 45 },
// ... other colors
},
borderRadius: 'rounded',
typography: {
heading: 'Poppins',
body: 'Inter',
code: 'Fira Code',
},
}
// Use in plugin config
themeManagementPlugin({
themePresets: [myTheme],
defaultTheme: 'my-custom-theme',
})Migrating from Older Versions
See MIGRATION_GUIDE.md for detailed migration instructions.
Key Changes in v0.1.9+
- Server components must import from
/server - Added
server-onlypackage to prevent client bundling errors - Removed
ServerThemeInjectorfrom main entry point
- import { ServerThemeInjector } from '@kilivi/payloadcms-theme-management'
+ import { ServerThemeInjector } from '@kilivi/payloadcms-theme-management/server'Testing
See TEST_APP_GUIDE.md for instructions on creating a test application.
Troubleshooting
Type Conflicts: Type 'SiteSetting' is not assignable to type 'SiteSetting'
Problem: You see errors about incompatible types even though you're passing the correct data structure.
Why This Happens: Your app's generated payload-types.ts might have slightly different type definitions than the plugin's (e.g., different font options, field variations).
Solution: Update to v0.1.11+ which uses generic types instead of strict payload-types:
pnpm update @kilivi/payloadcms-theme-management@latestThe plugin now accepts any compatible theme configuration structure, regardless of your Payload version or type variations. See TYPE_INDEPENDENCE_GUIDE.md for technical details.
Module not found: Can't resolve 'fs/promises'
Solution: Make sure you're using v0.1.9+ and importing server components from /server:
import { ServerThemeInjector } from '@kilivi/payloadcms-theme-management/server'Then clear your cache:
rm -rf .next node_modules/.cache
pnpm installTheme Not Applying
- Verify
ServerThemeInjectoris in your<head>tag - Check that site settings exist with theme configuration
- Inspect page source - should see
<style>tag with CSS variables - Ensure Tailwind/CSS is configured to use the CSS variables
TypeScript Errors
# Regenerate Payload types
pnpm payload generate:types
# Restart TypeScript server
# VS Code: Ctrl+Shift+P → "TypeScript: Restart TS Server"Documentation
- Type Independence Guide - Understanding type safety without coupling
- Migration Guide - Upgrading from older versions
- Test App Guide - Create a test application
- Server/Client Separation - Technical details
- Build Setup - Build configuration details
Development
# Install dependencies
pnpm install
# Build the plugin
pnpm build
# Watch mode for development
pnpm dev
# Clean build artifacts
pnpm cleanContributing
Contributions are welcome! Please:
- Fork the repository
- Create a feature branch
- Make your changes
- Add tests if applicable
- Submit a pull request
Troubleshooting
Common Issues
Error: "right-hand side of 'in' should be an object, got undefined"
This is a known Payload CMS issue related to user preferences. See TROUBLESHOOTING.md for solutions.
Quick Fix: Go to /admin/account and click "Reset Preferences" at the bottom of the page.
License
Apache-2.0
Author
Created for Payload CMS v3 applications.
Links
Changelog
v1.0.2 (Latest)
- ✅ Added: Automatic cache invalidation for standalone globals
- ✅ Fixed: Correct Payload CMS v3 API usage (findGlobal instead of non-existent findBySlug)
- ✅ Improved: Multi-tenant documentation with proper examples
- ✅ Improved: Cache optimization examples with unstable_cache
- ✅ Updated: README and Multi-Tenant guide with accurate API methods
v1.0.0
- ✅ Added: Standalone Global Support — create separate appearance settings as a global
- ✅ Added: Auto-populate Light/Dark colors when selecting a theme
- ✅ Added: Multi-tenant support and tenant-aware theme fetching
- ✅ Fixed: Cleaner data structure for improved API integration
- ✅ Added: Live Theme Preview at
/admin/theme-preview(real-time updates) - ✅ Added: Professional color picker (react-colorful)
- ✅ Improved: shadcn/ui and TweakCN compatibility
- ✅ Improved: SSR theme injection, zero FOUC, and performance optimizations
- ✅ Added: Server/client component separation
- ✅ Added:
server-onlypackage to prevent bundling errors - ✅ Changed:
ServerThemeInjectornow exported from/serverentry
v0.6.0
- Initial release with core theming functionality
