Skip to content
Open
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
---
title: 'Custom Providers'
metaTitle: 'Vendure Dashboard Custom Providers — Wrap the App & Layout'
metaDescription: 'Learn how to register custom React providers in Vendure Dashboard extensions, where they render (app vs layout) and how ordering works.'
---

Custom Providers let your extension **wrap parts of the Dashboard UI in your own React provider components**.
This is useful when you need to inject cross-cutting concerns like custom context, feature flags, error boundaries,
telemetry, or theming that should apply to dashboard pages.

## What is a "Custom Provider"?

A custom provider is a React component that receives `children` and returns a wrapped subtree:

```tsx
export function MyProvider({ children }: { children: ReactNode }) {
return children;
}
```

You register it via `defineDashboardExtension()`:

```tsx
import { defineDashboardExtension } from '@vendure/dashboard';

function MyProvider({ children }: { children: ReactNode }) {
return children;
}

export default defineDashboardExtension({
customProviders: [
{
id: 'my-provider',
component: MyProvider,
location: 'app',
order: 0,
},
],
});
```

## Where providers render: `location`

Each provider can target one of two places:

- **`location: 'app'`**: wraps the whole application (the highest level available to extensions).
- **`location: 'layout'`**: wraps the main content area of the authenticated layout (the `<Outlet />` subtree). The sidebar and header are outside this wrapper.

If `location` is omitted, it defaults to `'app'`.

## Provider ordering: `order`

Providers at the same `location` are sorted by `order` (ascending).

- Lower `order` values are rendered **outermost / earlier**.
- Higher `order` values are rendered **innermost / later**.

If `order` is omitted, it defaults to `0`.

## Common use cases

- Providing a custom React context your extension components can consume
- Adding an `ErrorBoundary` around parts of the dashboard
- Wiring up feature flags or experimentation frameworks
- Adding analytics/telemetry providers
- Providing localization or formatting helpers specific to your organization
5 changes: 5 additions & 0 deletions docs/src/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -594,6 +594,11 @@ const manifestInput: DocsPackageManifestInput = {
slug: 'migration',
file: file('docs/guides/extending-the-dashboard/migration/index.mdx'),
},
{
title: 'Custom Providers',
slug: 'custom-providers',
file: file('docs/guides/extending-the-dashboard/custom-providers/index.mdx'),
},
],
},
{
Expand Down
5 changes: 4 additions & 1 deletion packages/dashboard/src/app/app-providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { I18nProvider } from '@/vdb/providers/i18n-provider.js';
import { ServerConfigProvider } from '@/vdb/providers/server-config.js';
import { ThemeProvider } from '@/vdb/providers/theme-provider.js';
import { UserSettingsProvider } from '@/vdb/providers/user-settings.js';
import { CustomProviders } from '@/vdb/framework/extension-api/custom-providers.js';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';

Expand All @@ -19,7 +20,9 @@ export function AppProviders({ children }: { children: React.ReactNode }) {
<AuthProvider>
<ServerConfigProvider>
<ChannelProvider>
<AlertsProvider>{children}</AlertsProvider>
<AlertsProvider>
<CustomProviders location={'app'}>{children}</CustomProviders>
</AlertsProvider>
</ChannelProvider>
</ServerConfigProvider>
</AuthProvider>
Expand Down
5 changes: 4 additions & 1 deletion packages/dashboard/src/lib/components/layout/app-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
DashboardToolbarItemDefinition,
ToolbarItemPosition,
} from '@/vdb/framework/extension-api/types/toolbar.js';
import { CustomProviders } from '@/vdb/framework/extension-api/custom-providers.js';
import { getToolbarItemRegistry } from '@/vdb/framework/toolbar/toolbar-extensions.js';
import { useUserSettings } from '@/vdb/hooks/use-user-settings.js';
import { Outlet } from '@tanstack/react-router';
Expand Down Expand Up @@ -150,7 +151,9 @@ export function AppLayout() {
</div>
</div>
</header>
<Outlet />
<CustomProviders location={'layout'}>
<Outlet />
</CustomProviders>
</div>
</SidebarInset>
</SidebarProvider>
Expand Down
130 changes: 130 additions & 0 deletions packages/dashboard/src/lib/framework/extension-api/custom-providers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { globalRegistry } from '@/vdb/framework/registry/global-registry.js';
import { ComponentType, createElement, ReactNode, useMemo } from 'react';

import { useDashboardExtensions } from './use-dashboard-extensions.js';

/**
* @description
* Allows you to define custom React providers that wrap selected parts of the dashboard UI.
* This is useful for cross-cutting concerns such as custom context, error boundaries,
* feature flags, telemetry, or theming.
*
* Providers can be mounted at either the application root (`'app'`) or the authenticated
* layout main content area (`'layout'`, i.e. the `<Outlet />` subtree only; sidebar and
* header are outside this wrapper).
*
* @docsCategory extensions-api
* @docsPage Custom Providers
* @since 3.7.0
*/
export type DashboardCustomProviderDefinition = {
/**
* @description
* A unique identifier for this custom provider.
*/
id: string;
/**
* @description
* The React provider component to render. It receives `children` and should
* return a wrapped subtree.
*/
component: ComponentType<{ children: ReactNode }>;
/**
* @description
* Optional. Controls render order relative to other providers at the same location.
* Lower numbers render first (outermost), higher numbers render later (innermost).
*/
order?: number;
/**
* @description
* Determines where this provider is mounted in the dashboard hierarchy.
*
* - `'app'`: Wraps the entire dashboard application at the root level.
* - `'layout'`: Wraps the main content area of the authenticated layout (the `<Outlet />` subtree).
*
* The sidebar and header are outside this wrapper.
*
* Optional. Defaults to 'app' if not specified.
*/
location?: 'app' | 'layout';
};

globalRegistry.register(
'dashboardCustomProvidersRegistry',
new Map<string, DashboardCustomProviderDefinition>(),
);

export function getDashboardCustomProvidersRegistry() {
return globalRegistry.get('dashboardCustomProvidersRegistry');
}

export function registerDashboardCustomProvider(customProvider: DashboardCustomProviderDefinition) {
globalRegistry.set('dashboardCustomProvidersRegistry', map => {
map.set(customProvider.id, {
...customProvider,
location: customProvider.location ?? 'app',
});
return map;
});
Comment on lines +57 to +68
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Enforce duplicate-id checks on every public write path.

registerDashboardCustomProviders() rejects collisions, but the two exports here still allow silent last-writer-wins behavior: getDashboardCustomProvidersRegistry() returns the live Map, and registerDashboardCustomProvider() overwrites existing entries with map.set(). That makes the “globally unique” invariant depend on which helper the caller uses.

Suggested hardening
-export function getDashboardCustomProvidersRegistry() {
+function getDashboardCustomProvidersRegistry() {
     return globalRegistry.get('dashboardCustomProvidersRegistry');
 }
+
+export function getRegisteredDashboardCustomProviders(): ReadonlyMap<
+    string,
+    DashboardCustomProviderDefinition
+> {
+    return getDashboardCustomProvidersRegistry();
+}
 
 export function registerDashboardCustomProvider(customProvider: DashboardCustomProviderDefinition) {
+    if (getDashboardCustomProvidersRegistry().has(customProvider.id)) {
+        throw new Error(
+            `Duplicate dashboard custom provider ids detected: "${customProvider.id}". ` +
+                `Provider ids must be globally unique.`,
+        );
+    }
     globalRegistry.set('dashboardCustomProvidersRegistry', map => {
         map.set(customProvider.id, {
             ...customProvider,
             location: customProvider.location ?? 'app',
         });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/dashboard/src/lib/framework/extension-api/custom-providers.ts`
around lines 35 - 46, getDashboardCustomProvidersRegistry currently exposes the
live Map and registerDashboardCustomProvider silently overwrites existing
entries, allowing last-writer-wins; change getDashboardCustomProvidersRegistry
to return an immutable/read-only copy (e.g., new Map(map) or Object.freeze
wrapper) so callers cannot mutate the live registry, and update
registerDashboardCustomProvider to perform a collision check inside its updater
(use map.has(customProvider.id)) and throw an error if the id already exists
before calling map.set; reference getDashboardCustomProvidersRegistry and
registerDashboardCustomProvider and the 'dashboardCustomProvidersRegistry'
registry key when making the changes.

}

export function registerDashboardCustomProviders(providers: DashboardCustomProviderDefinition[] | undefined) {
if (!providers?.length) {
return;
}
const registry = getDashboardCustomProvidersRegistry();
const allIds = [...registry.keys(), ...providers.map(p => p.id)];
const seen = new Set<string>();
const duplicateIds = new Set<string>();
for (const id of allIds) {
if (seen.has(id)) {
duplicateIds.add(id);
} else {
seen.add(id);
}
}

if (duplicateIds.size) {
const duplicates = Array.from(duplicateIds).sort();
throw new Error(
`Duplicate dashboard custom provider ids detected: ` +
`${duplicates.map(id => `"${id}"`).join(', ')}. ` +
`Provider ids must be globally unique.`,
);
}

for (const provider of providers) {
registerDashboardCustomProvider(provider);
}
}

export const renderProviders = (
providers: DashboardCustomProviderDefinition[],
children: ReactNode,
): ReactNode => {
if (providers.length === 0) {
return children;
}

const [currentProvider, ...remainingProviders] = providers;
const ProviderComponent = currentProvider.component;

return createElement(ProviderComponent, null, renderProviders(remainingProviders, children));
};

export interface CustomProvidersProps {
location: DashboardCustomProviderDefinition['location'];
children: ReactNode;
}

export function CustomProviders({ location, children }: Readonly<CustomProvidersProps>) {
const { extensionsLoaded, reloadCount } = useDashboardExtensions();
const providersToRender = useMemo(() => {
const customProviders = Array.from(getDashboardCustomProvidersRegistry().values());
return customProviders
.filter(provider => provider.location === location)
.sort((a, b) => (a.order || 0) - (b.order || 0));
}, [extensionsLoaded, reloadCount, location]);

return renderProviders(providersToRender, children);
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import {
Expand All @@ -12,6 +14,7 @@ import {
} from '../nav-menu/nav-menu-extensions.js';
import { globalRegistry } from '../registry/global-registry.js';

import { getDashboardCustomProvidersRegistry, renderProviders } from './custom-providers.js';
import {
defineDashboardExtension,
executeDashboardExtensionCallbacks,
Expand All @@ -29,6 +32,11 @@ function resetWidgetRegistry() {
globalRegistry.set('dashboardWidgetRegistry', () => new Map<string, DashboardWidgetDefinition>());
}

function resetCustomProvidersRegistry() {
globalRegistry.set('dashboardCustomProvidersRegistry', () => new Map());
(globalRegistry as any).registry.set('registerDashboardExtensionCallbacks', new Set<() => void>());
}

describe('defineDashboardExtension - navSections', () => {
beforeEach(() => {
resetNavState();
Expand Down Expand Up @@ -297,3 +305,72 @@ describe('DashboardWidgetDefinition - requiresPermissions', () => {
expect(widget?.requiresPermissions).toEqual([]);
});
});

describe('Dashboard custom providers', () => {
beforeEach(() => {
resetCustomProvidersRegistry();
});

it('renders providers recursively in nesting order', () => {
const ProviderA = ({ children }: { children: React.ReactNode }) =>
createElement('section', { 'data-provider': 'a' }, children);
const ProviderB = ({ children }: { children: React.ReactNode }) =>
createElement('section', { 'data-provider': 'b' }, children);

const result = renderProviders(
[
{ id: 'provider-a', component: ProviderA, location: 'app' },
{ id: 'provider-b', component: ProviderB, location: 'app' },
],
createElement('div', { id: 'content' }, 'content'),
);

const html = renderToStaticMarkup(createElement('div', null, result));
expect(html).toContain('<section data-provider="a"><section data-provider="b">');
expect(html).toContain('<div id="content">content</div>');
});

it('registers providers sorted by order (ascending)', () => {
const callOrder: string[] = [];

const ProviderFirst = ({ children }: { children: React.ReactNode }) => {
callOrder.push('first');
return createElement('section', { 'data-provider': 'first' }, children);
};
const ProviderSecond = ({ children }: { children: React.ReactNode }) => {
callOrder.push('second');
return createElement('section', { 'data-provider': 'second' }, children);
};

defineDashboardExtension({
customProviders: [
{ id: 'second', component: ProviderSecond, order: 20, location: 'app' },
{ id: 'first', component: ProviderFirst, order: 10, location: 'app' },
],
});
executeDashboardExtensionCallbacks();

const providers = Array.from(getDashboardCustomProvidersRegistry().values()).sort(
(a, b) => (a.order || 0) - (b.order || 0),
);
const tree = renderProviders(providers, createElement('div', null, 'leaf'));
renderToStaticMarkup(createElement('div', null, tree));

expect(callOrder).toEqual(['first', 'second']);
});

it('throws when duplicate custom provider ids are registered', () => {
const Provider = ({ children }: { children: React.ReactNode }) => children;

defineDashboardExtension({
customProviders: [{ id: 'duplicate-provider', component: Provider }],
});
defineDashboardExtension({
customProviders: [{ id: 'duplicate-provider', component: Provider }],
});

expect(() => executeDashboardExtensionCallbacks()).toThrow(
'Duplicate dashboard custom provider ids detected: "duplicate-provider". Provider ids must be globally unique.',
);
});
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { getNavMenuConfig, setNavMenuConfig } from '../nav-menu/nav-menu-extensions.js';
import { globalRegistry } from '../registry/global-registry.js';

import { registerDashboardCustomProviders } from './custom-providers.js';
import { DashboardExtension } from './extension-api-types.js';
import {
registerAlertExtensions,
Expand Down Expand Up @@ -118,6 +119,9 @@ export function defineDashboardExtension(extension: DashboardExtension) {
// Register custom history entry components
registerHistoryEntryComponents(extension.historyEntries);

// Register dashboard custom providers
registerDashboardCustomProviders(extension.customProviders);

Comment on lines +122 to +124
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Prevent silent custom provider ID collisions.

At Line 123, providers are registered without collision feedback. Since registration is keyed by id, duplicates can silently overwrite earlier providers, making extension composition order-dependent.

Suggested guard (in custom-providers.ts)
 export function registerDashboardCustomProvider(customProvider: DashboardCustomProviderDefinition) {
     globalRegistry.set('dashboardCustomProvidersRegistry', map => {
+        if (map.has(customProvider.id)) {
+            throw new Error(
+                `Duplicate dashboard custom provider id "${customProvider.id}". ` +
+                `Provider ids must be unique across extensions.`,
+            );
+        }
         map.set(customProvider.id, {
             ...customProvider,
             location: customProvider.location ?? 'app',
         });
         return map;
     });
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@packages/dashboard/src/lib/framework/extension-api/define-dashboard-extension.ts`
around lines 122 - 124, The call to
registerDashboardCustomProviders(extension.customProviders) can silently
overwrite providers with duplicate ids; update the registration logic (in
custom-providers.ts / the registerDashboardCustomProviders function) to validate
ids before inserting: detect duplicates within the incoming
extension.customProviders and against the existing global provider registry, and
then either throw an informative error or emit a logged warning including the
conflicting provider id and both provider sources; do not perform the overwrite
without explicit handling. Ensure the check references the provider id field and
include the extension identifier when reporting conflicts so callers can resolve
collisions.

// Register toolbar extensions
registerToolbarExtensions(extension.toolbarItems);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Import types for the main interface
import { NavMenuConfig } from '../nav-menu/nav-menu-extensions.js';

import { DashboardCustomProviderDefinition } from './custom-providers.js';
import {
DashboardActionBarItem,
DashboardAlertDefinition,
Expand Down Expand Up @@ -128,4 +129,12 @@ export interface DashboardExtension {
* @since 3.5.3
*/
toolbarItems?: DashboardToolbarItemDefinition[];

/**
* @description
* Allows you to add custom providers in different locations.
*
* @since 3.7.0
*/
customProviders?: DashboardCustomProviderDefinition[];
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { DashboardCustomProviderDefinition } from '@/vdb/framework/extension-api/custom-providers.js';
import {
BulkAction,
DashboardActionBarItem,
Expand Down Expand Up @@ -32,4 +33,5 @@ export interface GlobalRegistryContents {
historyEntries: Map<string, DashboardHistoryEntryComponent['component']>;
navMenuModifiers: Array<(config: NavMenuConfig) => NavMenuConfig>;
dashboardToolbarItemRegistry: Map<string, DashboardToolbarItemDefinition>;
dashboardCustomProvidersRegistry: Map<string, DashboardCustomProviderDefinition>;
}
Loading
Loading