How to add custom items to the sidebar
The stack9 sidebar supports a custom node type that lets you mount any React component inline in the navigation panel. This guide walks through the full flow using the File Manager sidebar in stack9-dxp as the reference implementation.
How it works
The framework renders the sidebar from a tree of S9Sitemap nodes defined in your app's JSON file (e.g. packages/stack9-stack/src/apps/my_app.json). Each node has a nodeType:
nodeType | What renders |
|---|---|
appNode | Top-level app tile in the app switcher |
menuGroup | Collapsible group heading |
link | A single nav link |
custom | Your React component, mounted inline |
When the framework encounters a custom node it looks up node.renderAs against a sidebarComponents registry that you pass in via instance config. If the key is found, the component is rendered inside an ErrorBoundary + Suspense wrapper. If not found, the slot renders nothing.
Step-by-step
1. Add a custom node to your app JSON
In packages/stack9-stack/src/apps/your_app.json, add a child with nodeType: "custom" and a unique renderAs key:
{
"name": "My App",
"key": "my_app",
"nodeType": "appNode",
"children": [
{
"key": "my-app-browse",
"name": "Browse",
"nodeType": "link",
"link": "/my_app/browse"
},
{
"key": "my-app-sidebar",
"name": "My App Sidebar",
"nodeType": "custom",
"renderAs": "my-app/sidebar"
}
]
}
Rules enforced by AppSchemaValidator:
renderAsis required whennodeTypeis"custom", forbidden otherwise.customnodes must not have children.- Nesting follows the same 4-level depth limit as all other node types.
2. Write the sidebar component
The component receives { node, app } props (SidebarSlotProps from @april9au/stack9-ui) but can ignore them if it doesn't need them. Keep the component signature compatible regardless — the contract is locked so the framework can pass new values without breaking your registry.
// packages/my-app-ui/src/components/sidebar/MyAppSidebar.tsx
import { SidebarSection, SidebarItem } from '@april9au/stack9-ui';
export function MyAppSidebar() {
return (
<SidebarSection title="My Section">
<SidebarItem
label="All Items"
href="/my_app/browse"
active={/* derive from current route */}
/>
</SidebarSection>
);
}
Use the SidebarSection and SidebarItem primitives exported from @april9au/stack9-ui instead of hand-rolling Tailwind. They consume the sidebar's themed CSS tokens (--nav-sidebar-bg-color, hover states, etc.) and keep the visual style consistent across all slots.
SidebarSection props:
| Prop | Type | Description |
|---|---|---|
title | string? | Section heading rendered as small caps label |
action | ReactNode? | Trailing element (typically a small + button) |
children | ReactNode | SidebarItem list |
SidebarItem props:
| Prop | Type | Description |
|---|---|---|
label | string | Item text |
href | string | Navigation target (use onClick to intercept) |
active | boolean? | Applies active highlight style |
badge | ReactNode? | Trailing count/badge (tabular-nums, right-aligned) |
icon | ReactNode? | Leading icon |
onClick | () => void? | Intercepts the click (prevents default href nav) |
3. Export a sidebar component registry from your package
Create (or extend) a lazy.tsx (or similar barrel) in your UI package that exports a sidebarComponents map. Naming the key "<module>/sidebar" avoids clashes with other modules.
// packages/my-app-ui/src/lazy.tsx
import type { ComponentType } from 'react';
import { MyAppSidebar } from './components/sidebar/MyAppSidebar';
type SidebarSlotProps = { node: unknown; app: unknown };
export const sidebarComponents: Record<
string,
ComponentType<SidebarSlotProps>
> = {
'my-app/sidebar': MyAppSidebar as unknown as ComponentType<SidebarSlotProps>,
};
The as unknown as ComponentType<SidebarSlotProps> cast is intentional — if your component doesn't declare the props at all (because it ignores them), this bridges the gap without forcing the component signature to carry unused parameters.
4. Register the component in instance config
In apps/stack9-frontend/src/app.stack9.instance.tsx, merge your package's registry into instanceConfig.sidebarComponents:
import * as myAppModule from '@april9au/stack9-my-app-ui/lazy';
export const instanceConfig: Stack9InstanceConfig = {
// ... other config ...
sidebarComponents: {
...myAppModule.sidebarComponents,
// other module sidebar registries
},
};
The spread approach means each module owns its own key strings — the instance just merges them. Key collisions will cause one component to silently shadow the other, so keep keys namespaced per module.
5. (Optional) Hoist a shared provider
If your sidebar component and your route need to share state (e.g. a selection, a DnD context, an upload queue), mount the provider once at the instance root via wrapInner rather than inside the component. This ensures the provider survives route changes.
import { MyAppProvider } from '@april9au/stack9-my-app-ui/lazy';
export const instanceConfig: Stack9InstanceConfig = {
// ...
wrapInner: children => <MyAppProvider>{children}</MyAppProvider>,
sidebarComponents: {
...myAppModule.sidebarComponents,
},
};
wrapInner wraps the entire inner shell (sidebar + main content area), so the provider is shared across both. Only use it when you genuinely need cross-boundary state — don't add providers here for components that are fully self-contained.
Reference implementation
The File Manager sidebar is the canonical example of this pattern:
| File | Purpose |
|---|---|
packages/stack9-stack/src/apps/file_manager.json | Declares the custom node with renderAs: "file-manager/sidebar" |
packages/stack9-file-manager-ui/src/components/sidebar/FileManagerSidebar.tsx | The sidebar React component |
packages/stack9-file-manager-ui/src/lazy.tsx | Exports the sidebarComponents registry |
apps/stack9-frontend/src/app.stack9.instance.tsx | Merges the registry into instanceConfig.sidebarComponents and hoists FileManagerProvider via wrapInner |
Why FileManagerSidebar wraps in ScreenProvider: the file manager's data hooks (useListTags, useListCollections, etc.) scope their SWR cache keys to the active stack9 Screen. Without a ScreenProvider, calling those hooks outside the route throws "called outside a screen". Wrapping in <ScreenProvider relativePath="file_manager/browse"> lets the sidebar fetch the same data as the page and deduplicate via the SWR cache. If your sidebar component doesn't use screen-scoped hooks, you don't need this.
Framework internals (for context)
These files are in stack9-monorepo — you shouldn't need to edit them, but they're useful to know about when debugging:
packages/stack9-sdk/src/models/core/S9App.ts—S9Sitemaptype and thenodeTypeunionpackages/stack9-ui/src/contexts/state/AppState.ts—SidebarSlotPropsandSidebarComponentRegistrytypespackages/stack9-ui/src/components/layout/SidebarNavigation/CustomSidebarSlot.tsx— resolvesrenderAsagainst the registry, wraps inErrorBoundary+Suspensepackages/stack9-ui/src/components/layout/SidebarNavigation/primitives.tsx—SidebarSectionandSidebarItemsourcepackages/stack9-ui/src/components/layout/SidebarNavigation/SidebarAppMenuNavigation.tsx— wherecustomnodes branch toCustomSidebarSlot