Skip to content

Refactor sidebar to remove raw DOM manipulation #11

@will-lamerton

Description

@will-lamerton

Summary

The sidebar currently relies on raw DOM manipulation (using document.createElement, insertBefore, etc.) to inject project and version selectors. This approach is fragile, causes issues with Nextra integration, and should be replaced with proper React patterns.

Problem Description

From components/DocsWrapper.tsx, the current implementation uses:

useEffect(() => {
  if (versions.length === 0) return;

  const sidebar = document.querySelector("aside.nextra-sidebar");
  if (!sidebar || sidebar.querySelector(".version-selector-container"))
    return;

  const projDiv = document.createElement("div");
  projDiv.className = "project-selector-container";
  const verDiv = document.createElement("div");
  verDiv.className = "version-selector-container";

  sidebar.insertBefore(verDiv, sidebar.firstChild);
  sidebar.insertBefore(projDiv, sidebar.firstChild);

  setProjectContainer(projDiv);
  setVersionContainer(verDiv);
}, [versions]);

This approach has several issues:

  1. Fragile - Depends on specific Nextra internal class names (aside.nextra-sidebar)
  2. Race conditions - Timing issues with Nextra's rendering
  3. No TypeScript safety - Direct DOM manipulation bypasses React's type checking
  4. Hydration issues - Can cause mismatches between server and client
  5. Maintenance burden - Breaks when Nextra updates their class names or structure
  6. Testing difficulty - Harder to test and debug

Nextra v4 Sidebar API Research

Investigation of Nextra v4's API reveals that the Layout component does not provide sidebar content injection props:

Feature Available?
sidebar.header / sidebarHeader prop No
sidebar.footer / sidebarFooter prop No
Sidebar slot or render prop No
SidebarProvider / SidebarContext export No
Sidebar customization hooks Read-only (useThemeConfig())

The Layout sidebar prop only accepts:

sidebar?: {
  autoCollapse?: boolean;
  defaultMenuCollapseLevel?: number;
  defaultOpen?: boolean;
  toggleButton?: boolean;
}

This confirms why DOM manipulation was used — Nextra v4 does not offer a supported way to inject custom content into the sidebar.

Available Nextra APIs

While there's no sidebar injection API, Nextra does provide:

  1. normalizePages() from nextra — Processes a PageMapItem[] into structured sidebar data (directories, files, active items). This is the same function Nextra uses internally to build its sidebar.
  2. useThemeConfig() from nextra-theme-docs — Read-only access to theme configuration.
  3. useConfig() from nextra-theme-docs — Access to current page context including hideSidebar.

Proposed Solution

Option 1: Custom Layout with normalizePages() (Recommended)

Replace Nextra's Layout with a custom layout that uses normalizePages() for sidebar data but renders a custom sidebar component with project/version selectors built in:

import { normalizePages } from 'nextra';

function CustomDocsLayout({ children, pageMap }) {
  const pathname = usePathname();
  const { docsDirectories, activeIndex } = normalizePages({
    list: pageMap,
    route: pathname,
  });

  return (
    <div className="flex">
      <aside className="w-64 shrink-0 border-r">
        <ProjectSelector />      {/* Direct React rendering */}
        <VersionSelector />       {/* Direct React rendering */}
        <SidebarNavigation items={docsDirectories} />
      </aside>
      <main className="flex-1">{children}</main>
    </div>
  );
}

Pros:

  • Full control over sidebar content and layout
  • No DOM manipulation
  • Type-safe, proper React patterns
  • Uses Nextra's official normalizePages() API for sidebar data

Cons:

  • Must reimplement sidebar features: collapsible folders, active state highlighting, mobile overlay, keyboard navigation, scroll persistence
  • Significant effort — Nextra's sidebar is non-trivial
  • Risk of losing polish that Nextra's built-in sidebar provides

Mitigation: The codebase already has a shadcn sidebar.tsx component (components/ui/sidebar.tsx) with SidebarProvider, collapsible groups, and mobile sheet support. This can be used as the foundation.

Option 2: Fork/Extend Nextra's Sidebar Component

If Nextra's sidebar source is accessible:

  1. Copy Nextra's sidebar component implementation
  2. Add a header slot for project/version selectors
  3. Use this forked version instead of Nextra's built-in

Pros: Preserves all existing sidebar behavior while adding injection points.
Cons: Maintenance burden of keeping the fork in sync with Nextra updates.

Option 3: Keep Portal Pattern, Improve Robustness (Pragmatic)

If a full rewrite is too costly, improve the current approach:

  1. Add retry logic with MutationObserver instead of one-shot querySelector
  2. Use a more stable selector (data attribute if possible)
  3. Add error boundaries and fallback UI
  4. Document the fragility

Pros: Minimal effort, fixes immediate reliability issues.
Cons: Still fragile, still DOM manipulation.

Recommendation

Option 1 is recommended for long-term maintainability, using the existing shadcn sidebar.tsx as a foundation. However, this is a significant piece of work that affects mobile (issue #3), nested folders (issue #2), and page meta (issue #1).

Consider Option 3 as an interim fix if other issues are blocked by sidebar reliability problems.

Requirements

  1. Remove document.createElement - No raw DOM manipulation in components
  2. Proper React patterns - Use state, props, and React rendering
  3. Maintain functionality - Project and version selectors still work
  4. Preserve sidebar features - Collapsible folders, active state, mobile overlay, keyboard nav
  5. Type safety - Full TypeScript support
  6. Reliable - No race conditions or timing issues

Files Likely Affected

  • components/DocsWrapper.tsx - Remove useEffect DOM manipulation, remove createPortal pattern
  • app/[project]/docs/[version]/layout.tsx - Switch from Nextra Layout to custom layout (if Option 1)
  • components/ui/sidebar.tsx - Already exists as shadcn sidebar, can be foundation for custom sidebar
  • New components needed:
    • Custom docs layout wrapper
    • Sidebar navigation component (using normalizePages() data)

Dependencies

Migration Plan

  1. Build custom sidebar component using shadcn sidebar.tsx as foundation
  2. Integrate normalizePages() for page tree data
  3. Add project/version selectors as direct React children
  4. Create custom docs layout that replaces Nextra's Layout for doc pages
  5. Test with all projects, versions, and on mobile
  6. Remove old DOM manipulation code from DocsWrapper
  7. Verify no regressions in sidebar behavior (collapsing, active states, etc.)

Success Criteria

  • No document.createElement or insertBefore calls
  • No document.querySelector for Nextra internal class names
  • Project selector renders via React, not DOM injection
  • Version selector renders via React, not DOM injection
  • Sidebar folders are collapsible
  • Active page is highlighted in sidebar
  • Mobile sidebar works (overlay/drawer)
  • No hydration mismatches
  • Full TypeScript support

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions