An enhanced sidebar plugin for Payload CMS that adds a tabbed navigation system to organize collections and globals into logical groups.
- Tabbed Navigation - Organize collections into separate tabs for cleaner navigation
- Vertical Tab Bar - Icon-based tabs on the left side of the sidebar
- Link Support - Add navigation links (like Dashboard) alongside tabs
- Custom Items - Add custom navigation items that can be merged into existing groups
- Badges - Show notification badges on tabs and navigation items (API-based or reactive provider)
- Custom Components - Replace any part of the sidebar with your own React components
- i18n Support - Full localization support for labels and groups
- Lucide Icons - Use any Lucide icon for tabs and links, or provide a custom icon component per tab
npm install @veiag/payload-enhanced-sidebar
# or
yarn add @veiag/payload-enhanced-sidebar
# or
pnpm add @veiag/payload-enhanced-sidebarSee this comment if you have issues with scss : #12 (comment)
import { payloadEnhancedSidebar } from '@veiag/payload-enhanced-sidebar'
import { buildConfig } from 'payload'
export default buildConfig({
// ... your config
plugins: [
payloadEnhancedSidebar({
// Works with defaults!
}),
],
})This will add:
- A Dashboard link at the top
- A default tab showing all collections and globals
- A logout button at the bottom
import { payloadEnhancedSidebar } from '@veiag/payload-enhanced-sidebar'
import { buildConfig } from 'payload'
export default buildConfig({
plugins: [
payloadEnhancedSidebar({
// Tabs and links in the sidebar
tabs: [
// Dashboard link
{
id: 'dashboard',
type: 'link',
href: '/',
icon: 'House',
label: { en: 'Dashboard', uk: 'Головна' },
},
// Content tab - shows specific collections
{
id: 'content',
type: 'tab',
icon: 'FileText',
label: { en: 'Content', uk: 'Контент' },
collections: ['posts', 'pages', 'categories'],
},
// Link to external documentation
{
id: 'docs',
type: 'link',
href: 'https://payloadcms.com/',
icon: 'BookOpen',
isExternal: true,
label: { en: 'Documentation', uk: 'Документація' },
},
// E-commerce tab with custom items
{
id: 'ecommerce',
type: 'tab',
icon: 'ShoppingCart',
label: { en: 'E-commerce', uk: 'E-commerce' },
collections: ['products', 'orders', 'customers'],
customItems: [
{
slug: 'analytics',
href: '/analytics',
label: { en: 'Analytics', uk: 'Аналітика' },
group: 'E-commerce', // Merge into existing group
},
{
slug: 'quick-add',
href: '/quick-add',
label: { en: 'Quick Add', uk: 'Швидке додавання' },
position: 'top', // Appears above all collection groups
},
],
},
// Settings tab with globals
{
id: 'settings',
type: 'tab',
icon: 'Settings',
label: { en: 'Settings', uk: 'Налаштування' },
collections: ['users'],
globals: ['site-settings', 'footer-settings'],
customItems: [
{
slug: 'api-keys',
href: '/api-keys',
label: { en: 'API Keys', uk: 'API Ключі' },
// No group - will appear at the bottom
},
{
slug:'external-link',
href: 'https://example.com',
isExternal: true,
label: { en: 'External Link', uk: 'Зовнішнє Посилання'}
}
],
},
],
// Show/hide logout button (default: true)
showLogout: true,
// Disable the plugin
disabled: false,
}),
],
})Array of tabs and links to show in the sidebar.
Tab (type: 'tab')
| Property | Type | Required | Description |
|---|---|---|---|
id |
string |
Yes | Unique identifier |
type |
'tab' |
Yes | Tab type |
icon |
IconName |
Yes* | Lucide icon name |
iconComponent |
SidebarComponent |
Yes* | Path to a custom icon component (string or { path, clientProps }) |
label |
LocalizedString |
Yes | Tab tooltip/label |
collections |
CollectionSlug[] |
No | Collections to show in this tab |
globals |
GlobalSlug[] |
No | Globals to show in this tab |
customItems |
SidebarTabItem[] |
No | Custom navigation items (see below) |
badge |
BadgeConfig |
No | Badge configuration for the tab icon |
access |
TabAccessFunction |
No | Server-side access control — return false to hide |
* Exactly one of
iconoriconComponentis required — they are mutually exclusive. If neithercollectionsnorglobalsare specified, the tab shows all collections and globals.
Link (type: 'link')
| Property | Type | Required | Description |
|---|---|---|---|
id |
string |
Yes | Unique identifier |
type |
'link' |
Yes | Link type |
icon |
IconName |
Yes* | Lucide icon name |
iconComponent |
SidebarComponent |
Yes* | Path to a custom icon component (string or { path, clientProps }) |
label |
LocalizedString |
Yes | Link tooltip/label |
href |
string |
Yes | URL |
isExternal |
boolean |
No | If true, href is absolute URL, if not, href is relative to admin route |
badge |
BadgeConfig |
No | Badge configuration for the link icon |
access |
TabAccessFunction |
No | Server-side access control — return false to hide |
* Exactly one of
iconoriconComponentis required — they are mutually exclusive.
Custom slot (type: 'custom')
Renders an arbitrary component in the tabs bar — useful for spacers, separators, decorative elements, etc. Does not open any navigation content.
| Property | Type | Required | Description |
|---|---|---|---|
id |
string |
Yes | Unique identifier |
type |
'custom' |
Yes | Custom slot type |
component |
SidebarComponent |
Yes | Component to render (string path or { path, clientProps }) |
access |
TabAccessFunction |
No | Server-side access control — return false to hide |
The component receives { id } plus any clientProps you pass. See Custom Components for details.
{
id: 'separator',
type: 'custom',
component: './components/Sidebar#TabSeparator',
}Custom items can be added to any tab:
{
slug: 'unique-slug', // Required: unique identifier
href: '/path', // Required: URL
label: { en: 'Label' }, // Required: display label
group: { en: 'Group Name' }, // Optional: merge into existing group or create new
isExternal: true, // Optional: if true, href is absolute URL
position: 'top', // Optional: 'top' | 'bottom' (default: 'bottom')
}Group behavior:
- If
groupmatches an existing collection group label, the item is added to that group - If
groupdoesn't match any existing group, a new group is created - If
groupis not specified, the item appears as ungrouped
Position behavior:
position: 'top'— item (or new custom group) appears above all collection/global groupsposition: 'bottom'— appears below all groups (default)- Has no effect on items that merge into an existing collection group via
group
Badges allow you to show notification counts on tabs and navigation items. There are three ways to configure badges:
Add a badge property to any tab or link in the tabs array:
tabs: [
{
id: 'orders',
type: 'tab',
icon: 'ShoppingCart',
label: 'Orders',
collections: ['orders'],
// Badge on the tab icon
badge: {
type: 'collection-count',
collectionSlug: 'orders',
color: 'error',
},
},
]Use the badges configuration to add badges to any sidebar item (collections, globals, or custom items):
payloadEnhancedSidebar({
badges: {
// Show document count for posts collection
posts: { type: 'collection-count', color: 'primary' },
// Custom API endpoint
orders: {
type: 'api',
endpoint: '/api/orders/pending',
responseKey: 'count',
color: 'error',
},
// Provider-based (reactive)
notifications: { type: 'provider', color: 'warning' },
},
})Automatically fetches document count from a collection.
{
type: 'collection-count',
collectionSlug?: string, // Defaults to item's slug
color?: BadgeColor, // 'default' | 'primary' | 'success' | 'warning' | 'error'
where?: object, // Optional filter query
}Fetches badge value from a custom API endpoint.
{
type: 'api',
endpoint: string, // API URL (relative or absolute)
method?: 'GET' | 'POST', // Default: 'GET'
responseKey?: string, // Key to extract from response. Default: 'count'
color?: BadgeColor,
}Uses reactive values from BadgeProvider context. Values update automatically when the provider changes.
{
type: 'provider',
slug?: string, // Key in provider values. Defaults to item's slug/id
color?: BadgeColor,
}For reactive badges (real-time updates, websockets, etc.), use the BadgeProvider:
- Create a provider component:
// components/MyBadgeProvider.tsx
'use client'
import { BadgeProvider } from '@veiag/payload-enhanced-sidebar'
import { useEffect, useState } from 'react'
export const MyBadgeProvider = ({ children }) => {
const [counts, setCounts] = useState({
orders: 0,
notifications: 0,
})
useEffect(() => {
// Fetch initial counts, subscribe to websocket, etc.
const ws = new WebSocket('wss://your-api/counts')
ws.onmessage = (e) => setCounts(JSON.parse(e.data))
return () => ws.close()
}, [])
return <BadgeProvider values={counts}>{children}</BadgeProvider>
}- Add it to Payload's providers:
// payload.config.ts
export default buildConfig({
admin: {
components: {
providers: ['./components/MyBadgeProvider#MyBadgeProvider'],
},
},
})- Configure badges to use the provider:
payloadEnhancedSidebar({
badges: {
orders: { type: 'provider', color: 'error' },
},
tabs: [
{
id: 'notifications',
type: 'link',
href: '/notifications',
icon: 'Bell',
label: 'Notifications',
badge: { type: 'provider', slug: 'notifications', color: 'warning' },
},
],
})Available colors: default, primary, success, warning, error
- Numbers up to 99 are shown as-is
- Numbers > 99 are shown as "99+"
- Zero or undefined values hide the badge
- Provider values can also be React nodes for custom rendering
You can control visibility of tabs, links, and custom items using an access function. It runs server-side and receives the current PayloadRequest, so you have full access to req.user, roles, permissions, etc.
tabs: [
{
id: 'admin-panel',
type: 'tab',
icon: 'Shield',
label: 'Admin',
collections: ['users', 'tenants'],
access: ({ req, item }) => {
return req.user?.role === 'admin'
},
},
{
id: 'reports',
type: 'link',
href: '/reports',
icon: 'BarChart',
label: 'Reports',
access: async ({ req }) => {
// async is supported
return Boolean(req.user)
},
},
]If access returns false, the tab button is hidden from the tabs bar and its content is not rendered.
customItems: [
{
slug: 'admin-tools',
href: '/admin-tools',
label: 'Admin Tools',
access: ({ req }) => req.user?.role === 'admin',
},
]Access function signatures:
// For tabs and links
type TabAccessFunction = (args: {
item: SidebarTab // full tab/link config
req: PayloadRequest
}) => boolean | Promise<boolean>
// For custom items
type ItemAccessFunction = (args: {
item: SidebarTabItem // full custom item config
req: PayloadRequest
}) => boolean | Promise<boolean>Default collections and globals already respect Payload's built-in access control — they are filtered by
visibleEntitiesautomatically. Theaccessfunction is only needed for tabs, links, and custom items.
Access functions are fail-closed: if req is not available (e.g. on certain error pages), all items with an access function will be hidden. This is a known limitation caused by a Payload bug where req is not passed to the Nav component on 404 admin pages.
If you have custom admin views, you must pass req to DefaultTemplate for access control to work correctly. Retrieve it from props.initPageResult.req:
import type { AdminViewProps } from 'payload'
import { DefaultTemplate } from '@payloadcms/next/templates'
export async function MyCustomView(props: AdminViewProps) {
const { initPageResult, params, searchParams } = props
const { permissions, req, visibleEntities } = initPageResult
const { i18n, locale, payload, user } = req
return (
<DefaultTemplate
i18n={i18n}
locale={locale}
params={params}
payload={payload}
permissions={permissions}
req={req}
searchParams={searchParams}
user={user ?? undefined}
visibleEntities={visibleEntities}
>
{/* your view content */}
</DefaultTemplate>
)
}Without req={req}, the sidebar will treat the page as unauthenticated and hide all access-controlled items.
Show/hide the logout button at the bottom of the tabs bar.
- Type:
boolean - Default:
true
Completely disable the plugin.
- Type:
boolean - Default:
false
You can replace any part of the sidebar with your own React components. The plugin registers them automatically in Payload's import map — no manual import map configuration needed.
payloadEnhancedSidebar({
customComponents: {
// Replace individual nav items (collections, globals, custom links)
NavItem: './components/Sidebar#MyNavItem',
// Replace group headers
NavGroup: './components/Sidebar#MyNavGroup',
// Replace the entire nav scroll area
NavContent: './components/Sidebar#MyNavContent',
// Replace every button in the tabs bar (tabs and links)
TabButton: './components/Sidebar#MyTabButton',
},
tabs: [
{
id: 'dashboard',
type: 'link',
href: '/',
// Custom icon for just this tab/link (mutually exclusive with `icon`)
iconComponent: './components/Sidebar#DashboardIcon',
label: 'Dashboard',
},
],
})All custom components are client components ('use client'). The plugin provides hooks to connect them to sidebar state:
| Hook | Description |
|---|---|
useNavItemState(href) |
{ isActive, isCurrentPage } — for custom NavItem |
useTabState(id) |
{ isActive } — for custom NavContent or TabButton |
useEnhancedSidebar() |
{ activeTabId, onTabChange } — full tab context |
→ See docs/custom-components.md for full documentation, prop types, and examples for each slot.
All labels support localized strings:
label: 'Simple string'
// or
label: {
en: 'English',
uk: 'Українська',
de: 'Deutsch',
}- Browse by Folder Button - Automatically shows folder view button when Payload folders are enabled (requires Payload v3.41.0+)
- Settings Menu Items - Integrates with Payload's SettingsMenu components (requires Payload v3.60.0+)
beforeNav/afterNavslots - Supports Payload'sadmin.components.beforeNavandadmin.components.afterNavslots (requires Payload v3.75.0+). Both slots are rendered inside the nav content area —beforeNavbeforebeforeNavLinks,afterNavafterafterNavLinks.
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
Found a bug or have a feature request? Please open an issue on GitHub.
MIT © VeiaG



