Skip to main content

Custom Sidebar Navigation

Add your own navigation links, grouped sections, and nested menus to the sidebar alongside (or instead of) the auto-generated spec navigation.

Quick Start

import { OmniSpecRenderer } from '@apiboost/omnispec'
import type { SidebarNavConfig } from '@apiboost/omnispec'

const sidebarNav: SidebarNavConfig = {
heading: 'Quick Links',
items: [
{ id: 'home', label: 'Home', href: '/' },
{ id: 'guides', label: 'Getting Started', href: '/docs/guides' },
{ id: 'status', label: 'API Status', href: 'https://status.example.com' },
],
}

<OmniSpecRenderer spec={specUrl} sidebarNav={sidebarNav} />

Custom links appear above the spec navigation with a divider between them.

Placement

Control where custom navigation renders relative to the spec-derived nav:

// Above spec nav (default)
sidebarNav={{ placement: 'before', items: [...] }}

// Below spec nav
sidebarNav={{ placement: 'after', items: [...] }}

// Replace spec nav entirely
sidebarNav={{ placement: 'replace', items: [...] }}
PlacementSpec nav visibleDivider shown
before (default)YesYes (configurable)
afterYesYes (configurable)
replaceNoNo

Hide the divider with showDivider: false.

Grouped Sections

Organize links into collapsible groups with labeled headings:

const sidebarNav: SidebarNavConfig = {
placement: 'before',
items: [
{
id: 'docs-group',
label: 'Documentation',
items: [
{ id: 'overview', label: 'Overview', href: '/docs' },
{ id: 'auth-guide', label: 'Authentication', href: '/docs/auth' },
{ id: 'rate-limits', label: 'Rate Limits', href: '/docs/rate-limits' },
],
},
{
id: 'resources-group',
label: 'Resources',
items: [
{ id: 'sdks', label: 'SDKs & Libraries', href: '/sdks' },
{ id: 'support', label: 'Support', href: 'https://support.example.com' },
],
},
],
}

Groups are collapsible — click the heading to expand/collapse. Groups start expanded by default; set defaultExpanded: false to start collapsed.

Nested Items

Items can have children for multi-level navigation (arbitrary depth):

const sidebarNav: SidebarNavConfig = {
items: [
{
id: 'guides',
label: 'Guides',
children: [
{ id: 'quickstart', label: 'Quickstart', href: '/docs/quickstart' },
{
id: 'auth-section',
label: 'Authentication',
children: [
{ id: 'oauth', label: 'OAuth 2.0', href: '/docs/auth/oauth' },
{ id: 'api-keys', label: 'API Keys', href: '/docs/auth/keys' },
{ id: 'jwt', label: 'JWT Tokens', href: '/docs/auth/jwt' },
],
},
],
},
],
}

URLs starting with http:// or https:// are automatically detected as external links. They open in a new tab and display an external-link icon:

{ id: 'github', label: 'GitHub', href: 'https://github.com/acme/api' }

Override the auto-detection with the target property:

// Force external URL to open in same tab
{ id: 'portal', label: 'Portal', href: 'https://portal.example.com', target: '_self' }

Badges

Add color-coded badges to highlight new or special items:

{
id: 'webhooks',
label: 'Webhooks',
href: '/docs/webhooks',
badge: 'New',
badgeColor: '#22c55e',
}

The badge prop accepts a string for simple text badges, or a ReactNode for custom rendering.

Icons

Pass any ReactNode as an icon — it renders before the label:

import { BookOpen, Code, Headphones } from 'lucide-react'

const sidebarNav: SidebarNavConfig = {
items: [
{ id: 'docs', label: 'Documentation', href: '/docs', icon: <BookOpen size={14} /> },
{ id: 'sdks', label: 'SDKs', href: '/sdks', icon: <Code size={14} /> },
{ id: 'support', label: 'Support', href: '/support', icon: <Headphones size={14} /> },
],
}

Active State

Uncontrolled (Default)

When no activeId is provided, the sidebar tracks active state automatically by matching item href values against the current URL hash or pathname.

  • href="#section-id" matches window.location.hash
  • href="/docs/guide" matches window.location.pathname

Controlled

For apps with their own routing (React Router, Next.js, etc.), pass activeId to control which item is highlighted:

import { useLocation } from 'react-router-dom'

function ApiPage() {
const location = useLocation()
const activeId = getNavIdFromPath(location.pathname)

return (
<OmniSpecRenderer
spec={specUrl}
sidebarNav={{
activeId,
onItemClick: (item) => {
if (item.href && !item.href.startsWith('http')) {
navigate(item.href)
return false // Prevent default navigation
}
},
items: navItems,
}}
/>
)
}

When activeId is provided, the component becomes "controlled" — you manage active state entirely.

Collapsing the Sidebar

On desktop, the sidebar can be collapsed to give the specification content the full width. A chevron control at the top of the sidebar collapses it to a thin rail; clicking the rail expands it again. This is built in and requires no configuration — it applies whether the sidebar shows spec-derived nav, custom sidebarNav, or host-injected slots. On mobile the sidebar is a drawer instead, opened and closed with the floating navigation button.

Click Handler

The onItemClick callback fires when any custom nav item is clicked. Return false to prevent the default navigation behavior:

sidebarNav={{
onItemClick: (item) => {
analytics.track('sidebar_click', { id: item.id, href: item.href })
// Return undefined to allow default navigation
// Return false to prevent it
},
items: [...],
}}

Combining with Slots

sidebarNav and slots work together. The full sidebar rendering order:

+-----------------------------------+
| slots.sidebarHeader |
+-----------------------------------+
| sidebarNav heading | (placement: "before")
| Custom nav items / groups |
+-----------------------------------+
| ──── divider ──── |
+-----------------------------------+
| Search bar | (spec-derived)
| Operations / Channels / Types |
| Schemas |
+-----------------------------------+
| sidebarNav heading | (placement: "after")
| Custom nav items / groups |
+-----------------------------------+
| slots.sidebarFooter |
+-----------------------------------+

Example combining both:

<OmniSpecRenderer
spec={specUrl}
slots={{
sidebarHeader: <CompanyLogo />,
sidebarFooter: <VersionInfo version="2.1.0" />,
}}
sidebarNav={{
placement: 'before',
heading: 'Docs',
items: [
{ id: 'overview', label: 'Overview', href: '/docs' },
{ id: 'changelog', label: 'Changelog', href: '/changelog' },
],
}}
/>

See Slots for details on all available slot positions.

TypeScript Reference

import type {
SidebarNavConfig,
SidebarNavItem,
SidebarNavGroup,
SidebarNavPlacement,
} from '@apiboost/omnispec'

SidebarNavItem

interface SidebarNavItem {
id: string // Unique identifier
label: string // Display text
href?: string // Navigation target (hash, path, or URL)
target?: '_self' | '_blank'
icon?: ReactNode // Icon before label
badge?: string | ReactNode
badgeColor?: string // Badge background color (string badges only)
children?: SidebarNavItem[]
defaultExpanded?: boolean
separator?: boolean // Render as non-interactive heading
className?: string // Custom CSS class
}

SidebarNavGroup

interface SidebarNavGroup {
id: string // Unique identifier
label: string // Section heading text
items: SidebarNavItem[] // Items in this group
defaultExpanded?: boolean
icon?: ReactNode // Icon for group heading
}

SidebarNavConfig

interface SidebarNavConfig {
items: Array<SidebarNavItem | SidebarNavGroup>
placement?: 'before' | 'after' | 'replace' // Default: 'before'
activeId?: string // Controlled active state
onItemClick?: (item: SidebarNavItem) => void | false
showDivider?: boolean // Default: true
heading?: string // Section heading above custom nav
}

Spec Type Support

Custom sidebar navigation works identically across all spec types — OpenAPI, AsyncAPI, GraphQL, SOAP, and gRPC. The buildSidebar helper that composes custom and spec navigation is shared across all renderers.

Full Example

A complete portal-style sidebar with guides, resources, external links, badges, and nested items:

import { OmniSpecRenderer } from '@apiboost/omnispec'
import type { SidebarNavConfig } from '@apiboost/omnispec'

const sidebarNav: SidebarNavConfig = {
placement: 'before',
showDivider: true,
items: [
{
id: 'guides',
label: 'Guides',
items: [
{ id: 'getting-started', label: 'Getting Started', href: '/' },
{ id: 'auth', label: 'Authentication', href: '/docs/auth' },
{ id: 'rate-limits', label: 'Rate Limits', href: '/docs/rate-limits' },
{ id: 'pagination', label: 'Pagination', href: '/docs/pagination' },
],
},
{
id: 'resources',
label: 'Resources',
items: [
{ id: 'changelog', label: 'Changelog', href: '/docs/changelog' },
{
id: 'sdks',
label: 'SDKs & Libraries',
defaultExpanded: false,
children: [
{ id: 'sdk-js', label: 'JavaScript', href: '/docs/sdks/javascript' },
{ id: 'sdk-python', label: 'Python', href: '/docs/sdks/python' },
{ id: 'sdk-go', label: 'Go', href: '/docs/sdks/go' },
],
},
{
id: 'webhooks',
label: 'Webhooks',
href: '/docs/webhooks',
badge: 'Beta',
badgeColor: '#f59e0b',
},
{ id: 'github', label: 'GitHub', href: 'https://github.com/acme/api' },
{ id: 'status', label: 'API Status', href: 'https://status.example.com' },
],
},
],
}

function OmniSpecPage() {
return (
<OmniSpecRenderer
spec="/specs/openapi.json"
theme={{ base: 'light' }}
sidebarNav={sidebarNav}
slots={{
sidebarHeader: <img src="/logo.svg" alt="Acme" style={{ padding: '0.75rem' }} />,
}}
/>
)
}