diff --git a/docs/docs/guides/extending-the-dashboard/custom-providers/index.mdx b/docs/docs/guides/extending-the-dashboard/custom-providers/index.mdx
new file mode 100644
index 0000000000..841bfa3732
--- /dev/null
+++ b/docs/docs/guides/extending-the-dashboard/custom-providers/index.mdx
@@ -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 `` 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
diff --git a/docs/src/manifest.ts b/docs/src/manifest.ts
index c83002f6d2..c00dbdddc8 100644
--- a/docs/src/manifest.ts
+++ b/docs/src/manifest.ts
@@ -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'),
+ },
],
},
{
diff --git a/packages/dashboard/src/app/app-providers.tsx b/packages/dashboard/src/app/app-providers.tsx
index 4028bd7302..cddd969350 100644
--- a/packages/dashboard/src/app/app-providers.tsx
+++ b/packages/dashboard/src/app/app-providers.tsx
@@ -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';
@@ -19,7 +20,9 @@ export function AppProviders({ children }: { children: React.ReactNode }) {
- {children}
+
+ {children}
+
diff --git a/packages/dashboard/src/lib/components/layout/app-layout.tsx b/packages/dashboard/src/lib/components/layout/app-layout.tsx
index 9981c31570..4f1993e263 100644
--- a/packages/dashboard/src/lib/components/layout/app-layout.tsx
+++ b/packages/dashboard/src/lib/components/layout/app-layout.tsx
@@ -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';
@@ -150,7 +151,9 @@ export function AppLayout() {
-
+
+
+
diff --git a/packages/dashboard/src/lib/framework/extension-api/custom-providers.ts b/packages/dashboard/src/lib/framework/extension-api/custom-providers.ts
new file mode 100644
index 0000000000..a51d046eeb
--- /dev/null
+++ b/packages/dashboard/src/lib/framework/extension-api/custom-providers.ts
@@ -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 `` 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 `` subtree).
+ *
+ * The sidebar and header are outside this wrapper.
+ *
+ * Optional. Defaults to 'app' if not specified.
+ */
+ location?: 'app' | 'layout';
+};
+
+globalRegistry.register(
+ 'dashboardCustomProvidersRegistry',
+ new Map(),
+);
+
+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;
+ });
+}
+
+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();
+ const duplicateIds = new Set();
+ 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) {
+ 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);
+}
diff --git a/packages/dashboard/src/lib/framework/extension-api/define-dashboard-extension.spec.ts b/packages/dashboard/src/lib/framework/extension-api/define-dashboard-extension.spec.ts
index cbf81603e1..72c01f129d 100644
--- a/packages/dashboard/src/lib/framework/extension-api/define-dashboard-extension.spec.ts
+++ b/packages/dashboard/src/lib/framework/extension-api/define-dashboard-extension.spec.ts
@@ -1,3 +1,5 @@
+import { createElement } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
@@ -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,
@@ -29,6 +32,11 @@ function resetWidgetRegistry() {
globalRegistry.set('dashboardWidgetRegistry', () => new Map());
}
+function resetCustomProvidersRegistry() {
+ globalRegistry.set('dashboardCustomProvidersRegistry', () => new Map());
+ (globalRegistry as any).registry.set('registerDashboardExtensionCallbacks', new Set<() => void>());
+}
+
describe('defineDashboardExtension - navSections', () => {
beforeEach(() => {
resetNavState();
@@ -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('');
+ expect(html).toContain('content
');
+ });
+
+ 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.',
+ );
+ });
+});
diff --git a/packages/dashboard/src/lib/framework/extension-api/define-dashboard-extension.ts b/packages/dashboard/src/lib/framework/extension-api/define-dashboard-extension.ts
index a6f9ac6bf1..96d10e9670 100644
--- a/packages/dashboard/src/lib/framework/extension-api/define-dashboard-extension.ts
+++ b/packages/dashboard/src/lib/framework/extension-api/define-dashboard-extension.ts
@@ -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,
@@ -118,6 +119,9 @@ export function defineDashboardExtension(extension: DashboardExtension) {
// Register custom history entry components
registerHistoryEntryComponents(extension.historyEntries);
+ // Register dashboard custom providers
+ registerDashboardCustomProviders(extension.customProviders);
+
// Register toolbar extensions
registerToolbarExtensions(extension.toolbarItems);
diff --git a/packages/dashboard/src/lib/framework/extension-api/extension-api-types.ts b/packages/dashboard/src/lib/framework/extension-api/extension-api-types.ts
index b1f0ece149..641d630491 100644
--- a/packages/dashboard/src/lib/framework/extension-api/extension-api-types.ts
+++ b/packages/dashboard/src/lib/framework/extension-api/extension-api-types.ts
@@ -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,
@@ -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[];
}
diff --git a/packages/dashboard/src/lib/framework/registry/registry-types.ts b/packages/dashboard/src/lib/framework/registry/registry-types.ts
index 13d5ad59d8..2a5dcf3d83 100644
--- a/packages/dashboard/src/lib/framework/registry/registry-types.ts
+++ b/packages/dashboard/src/lib/framework/registry/registry-types.ts
@@ -1,3 +1,4 @@
+import { DashboardCustomProviderDefinition } from '@/vdb/framework/extension-api/custom-providers.js';
import {
BulkAction,
DashboardActionBarItem,
@@ -32,4 +33,5 @@ export interface GlobalRegistryContents {
historyEntries: Map;
navMenuModifiers: Array<(config: NavMenuConfig) => NavMenuConfig>;
dashboardToolbarItemRegistry: Map;
+ dashboardCustomProvidersRegistry: Map;
}
diff --git a/packages/dashboard/src/lib/index.ts b/packages/dashboard/src/lib/index.ts
index d929aa3192..c631b4a3af 100644
--- a/packages/dashboard/src/lib/index.ts
+++ b/packages/dashboard/src/lib/index.ts
@@ -231,6 +231,7 @@ export * from './framework/document-introspection/get-document-structure.js';
export * from './framework/document-introspection/hooks.js';
export * from './framework/document-introspection/include-only-selected-list-fields.js';
export * from './framework/document-introspection/testing-utils.js';
+export * from './framework/extension-api/custom-providers.js';
export * from './framework/extension-api/define-dashboard-extension.js';
export * from './framework/extension-api/display-component-extensions.js';
export * from './framework/extension-api/extension-api-types.js';