Skip to content

React Router Migration for Admin Portal

Context

The admin portal currently uses custom hash-based routing (#/docs/editor) with a query parameter bug: when navigating from #/docs/editor?path=FILE.md to #/docs/preview, the ?path= parameter incorrectly persists on the preview tab where it shouldn't exist.

Root cause: The setHashSection() function in AdminDashboard.jsx preserves query params when navigating within the same "base section" (e.g., docsdocs), rather than checking the full section path.

Decision: Install React Router v6 in the admin portal to:

  1. Fix the query param stickiness bug
  2. Enable confident URL sharing for coworkers (proper /docs/editor URLs)
  3. Establish a more maintainable routing foundation

Scope: Admin portal only - the main PWA app will remain hash-based (appropriate for PWAs).


Implementation Strategy

Dependencies

Install React Router v6:

bash
npm install react-router-dom@^6.28.0 -w apps/admin

Route Structure

/                          → DashboardHome (default)
/docs                      → redirect to /docs/editor
  /docs/editor            → DocsEditor with editor tab
  /docs/preview           → DocsEditor with preview tab
  /docs/external          → DocsEditor with external editors tab
/storybook                 → StorybookEmbed
/users                     → UserManagement
/system                    → SystemHealth (internal tabs)
/billing                   → Billing (internal tabs)
/profile                   → MyAdminProfile
/feature-tracker           → FeatureTracker
/analytics                 → redirect to / (disabled)
/merchants                 → redirect to / (disabled)
*                          → redirect to / (catch-all 404)

Query parameters:

  • /docs/editor?path=docs/FILE.md - file selection (ONLY on editor route)
  • /?mode=resetPassword&oobCode=xxx - Firebase email action links

Critical Files to Modify

1. apps/admin/package.json

Change: Add react-router-dom dependency

json
"dependencies": {
  "react-router-dom": "^6.28.0"
}

2. apps/admin/src/App.jsx

Changes:

  • Wrap app with <BrowserRouter>
  • Replace getEmailActionParams() with useSearchParams hook
  • Replace clearEmailActionParams() with setSearchParams({})

Key sections:

  • Lines 1-10: Add import import { BrowserRouter, useSearchParams } from 'react-router-dom'
  • Lines 22-39: Replace URL param parsing
  • Lines 152-161: Replace clear function
  • Wrap return JSX with <BrowserRouter>

3. apps/admin/src/components/AdminDashboard.jsx

Changes (largest refactor):

  • Import Router components and hooks
  • Remove hash-based functions: getSectionFromHash(), setHashSection(), getBaseSection()
  • Remove activeSection state and hash change effects
  • Replace renderSection() switch statement with <Routes> and <Route> components
  • Update handleNavigate to use navigate() hook
  • Update sidebar active state detection to use location.pathname
  • Add backward compatibility redirect for old hash URLs

Sections to remove:

  • Lines 42-62: getSectionFromHash()
  • Lines 67-69: getBaseSection()
  • Lines 75-93: setHashSection()
  • Line 97: activeSection state
  • Lines 128-136: Hash change listener effect
  • Lines 139-141: Hash sync effect
  • Lines 149-175: renderSection() switch statement

New logic:

jsx
import { Routes, Route, Navigate, useNavigate, useLocation } from 'react-router-dom'

const navigate = useNavigate()
const location = useLocation()

const handleNavigate = (section) => {
  navigate(`/${section}`)
}

// In JSX:
<Routes>
  <Route path="/" element={<DashboardHome onNavigate={handleNavigate} />} />
  <Route path="/docs">
    <Route index element={<Navigate to="/docs/editor" replace />} />
    <Route path="editor" element={<DocsEditor activeTab="editor" />} />
    <Route path="preview" element={<DocsEditor activeTab="preview" />} />
    <Route path="external" element={<DocsEditor activeTab="external" />} />
  </Route>
  {/* ... other routes */}
  <Route path="*" element={<Navigate to="/" replace />} />
</Routes>

// Sidebar active state:
const isActive = (itemId) => {
  return location.pathname === `/${itemId}` ||
         location.pathname.startsWith(`/${itemId}/`)
}

Backward compatibility (add new effect):

jsx
useEffect(() => {
  const hash = window.location.hash
  if (hash && hash.startsWith('#/')) {
    const path = hash.replace(/^#\/?/, '')
    navigate(`/${path}`, { replace: true })
  }
}, [navigate])

4. apps/admin/src/components/SelfHostedDocsEditor.jsx

Changes:

  • Import useSearchParams hook
  • Replace manual hash parsing with searchParams.get('path')
  • Replace window.history.replaceState() with setSearchParams()

Key sections:

  • Line 1: Add import import { useSearchParams } from 'react-router-dom'
  • Lines 158-198: Simplify query param logic

Before:

jsx
const hash = window.location.hash || ''
const [rawHashBase, queryString] = hash.split('?')
const params = new URLSearchParams(queryString || '')
const currentPath = params.get('path')
// ... update with window.history.replaceState

After:

jsx
const [searchParams, setSearchParams] = useSearchParams()

// Read
const currentPath = searchParams.get('path')

// Write
setSearchParams({ path: selectedFile.path })

Implementation Steps

Step 1: Install Dependencies

bash
npm install react-router-dom@^6.28.0 -w apps/admin

Step 2: Wrap App with BrowserRouter

In apps/admin/src/App.jsx:

  • Import BrowserRouter
  • Wrap the entire return JSX with <BrowserRouter>

Step 3: Migrate App.jsx Query Params

Replace getEmailActionParams() and clearEmailActionParams() with useSearchParams hook.

Step 4: Migrate AdminDashboard.jsx

  • Import Router hooks and components
  • Remove all hash-based functions and state
  • Replace renderSection() with <Routes> structure
  • Update handleNavigate to use navigate()
  • Update sidebar active detection with location.pathname
  • Add backward compatibility redirect

Step 5: Migrate SelfHostedDocsEditor.jsx

Replace manual hash parsing with useSearchParams hook.

Step 6: Testing

Run validation and manual testing (see Verification section below).


Query Param Bug Fix

Current behavior (BUG):

  • Navigate from #/docs/editor?path=README.md to #/docs/preview
  • Result: #/docs/preview?path=README.md ❌ (param incorrectly persists)

After migration:

  • Navigate from /docs/editor?path=README.md to /docs/preview
  • Result: /docs/preview ✅ (React Router doesn't preserve params across routes by default)

The bug is fixed automatically because:

  1. Each route manages its own query params independently
  2. Only /docs/editor uses the ?path= param (via useSearchParams in SelfHostedDocsEditor)
  3. Navigating to /docs/preview or /docs/external creates a clean URL with no params

Edge Cases

1. Internal Tab State

SystemHealth, Billing, FeatureTracker use local state for tabs:

  • Action: No changes needed - keep as local state
  • Rationale: These are UI implementation details, not shareable routes

2. Disabled Routes

Analytics and Merchants redirect to dashboard:

jsx
<Route path="/analytics" element={<Navigate to="/" replace />} />
<Route path="/merchants" element={<Navigate to="/" replace />} />

3. Docs Folder Auto-Expand

Update to use location.pathname:

jsx
const [expandedSections, setExpandedSections] = useState(() => {
  return location.pathname.startsWith('/docs') ? { docs: true } : {}
})

Firebase sends ?mode=resetPassword&oobCode=xxx to root URL:

  • Action: Handle in App.jsx with useSearchParams
  • Clear params: Call setSearchParams({}) after handling

Backward Compatibility

Add redirect hook in AdminDashboard.jsx to handle old hash URLs during transition:

jsx
useEffect(() => {
  const hash = window.location.hash
  if (hash && hash.startsWith('#/')) {
    const path = hash.replace(/^#\/?/, '')
    navigate(`/${path}`, { replace: true })
  }
}, [navigate])

Examples:

  • http://localhost:3001/#/docs/editor/docs/editor
  • http://localhost:3001/#/users/users

Verification

Pre-Flight Checks

bash
# Install dependencies
npm install react-router-dom@^6.28.0 -w apps/admin

# Build and validate
npm run validate -w apps/admin

Manual Testing Checklist

Basic Navigation:

  • [ ] Navigate to / → shows dashboard
  • [ ] Navigate to /docs → redirects to /docs/editor
  • [ ] Navigate to /docs/editor → shows docs editor
  • [ ] Navigate to /docs/preview → shows docs preview
  • [ ] Navigate to /docs/external → shows external editors
  • [ ] Navigate to /storybook, /users, /system, /billing, /profile, /feature-tracker
  • [ ] Navigate to /analytics or /merchants → redirects to /
  • [ ] Navigate to /invalid-route → redirects to /

Query Param Bug Fix (CRITICAL):

  • [ ] Open file in docs editor: /docs/editor?path=docs/CONTRIBUTING.md
  • [ ] Verify URL shows ?path= parameter ✅
  • [ ] Click "Preview" tab → navigate to /docs/preview
  • [ ] Verify URL does NOT have ?path= parameter ✅ (BUG FIXED)
  • [ ] Click back to "Editor" tab
  • [ ] Verify file is still selected (URL updates to /docs/editor?path=...)
  • [ ] Click "External Editors" tab
  • [ ] Verify URL does NOT have ?path= parameter

Email Action Links:

  • [ ] Simulate password reset link: /?mode=resetPassword&oobCode=test123
  • [ ] Verify SetPassword component renders
  • [ ] Complete/cancel flow
  • [ ] Verify query params are cleared from URL
  • [ ] Simulate admin reset link: /?mode=adminReset&token=test456
  • [ ] Verify SetAdminPassword component renders

Browser Navigation:

  • [ ] Click through several routes
  • [ ] Press browser back button → previous route loads
  • [ ] Press browser forward button → next route loads
  • [ ] Verify sidebar highlights correct active section

Backward Compatibility:

  • [ ] Load old hash URL: http://localhost:3001/#/users
  • [ ] Verify it redirects to /users (no hash)
  • [ ] Load old hash URL with nested route: http://localhost:3001/#/docs/preview
  • [ ] Verify it redirects to /docs/preview

Sidebar Active State:

  • [ ] Navigate to each route
  • [ ] Verify sidebar shows correct highlighted item
  • [ ] For docs routes, verify docs section expands and correct sub-item highlights

Dev Server:

bash
npm run dev -w apps/admin
# Test at http://localhost:3001

Automated Testing

bash
# Run tests
npm test -w apps/admin

# Run full validation
npm run validate -w apps/admin

Rollback Plan

If issues arise, revert the migration:

  1. Revert package.json: Remove react-router-dom dependency
  2. Restore hash-based routing: Revert AdminDashboard.jsx, App.jsx, SelfHostedDocsEditor.jsx
  3. Run validation: npm run validate -w apps/admin

The migration is low-risk because:

  • All changes are isolated to 4 files
  • Child components remain unchanged (they use callbacks)
  • No database or API changes
  • Can be reverted with a single git command

Post-Migration

Cleanup

After successful deployment:

  • Remove backward compatibility redirect after 30 days
  • Update any documentation referencing hash URLs

Future Enhancements

This migration enables:

  • Route-level code splitting with React.lazy()
  • Type-safe routing with TypeScript (if added later)
  • Route guards for permission-based access
  • More complex nested routes as admin features grow

Summary

Changes:

  • 4 files modified: package.json, App.jsx, AdminDashboard.jsx, SelfHostedDocsEditor.jsx
  • 1 dependency added: react-router-dom@^6.28.0

Fixes:

  • ✅ Query param stickiness bug resolved
  • ✅ Proper shareable URLs (/docs/editor instead of #/docs/editor)
  • ✅ More maintainable routing foundation
  • ✅ Browser back/forward support (improved)
  • ✅ Backward compatible with old hash URLs

Risk Level: LOW

  • Clear scope (4 files)
  • No breaking changes to child components
  • Easy rollback
  • Comprehensive testing plan

Estimated Effort: 4-6 hours

Built with VitePress