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., docs → docs), rather than checking the full section path.
Decision: Install React Router v6 in the admin portal to:
- Fix the query param stickiness bug
- Enable confident URL sharing for coworkers (proper
/docs/editorURLs) - 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:
npm install react-router-dom@^6.28.0 -w apps/adminRoute 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
"dependencies": {
"react-router-dom": "^6.28.0"
}2. apps/admin/src/App.jsx
Changes:
- Wrap app with
<BrowserRouter> - Replace
getEmailActionParams()withuseSearchParamshook - Replace
clearEmailActionParams()withsetSearchParams({})
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
activeSectionstate and hash change effects - Replace
renderSection()switch statement with<Routes>and<Route>components - Update
handleNavigateto usenavigate()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:
activeSectionstate - Lines 128-136: Hash change listener effect
- Lines 139-141: Hash sync effect
- Lines 149-175:
renderSection()switch statement
New logic:
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):
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
useSearchParamshook - Replace manual hash parsing with
searchParams.get('path') - Replace
window.history.replaceState()withsetSearchParams()
Key sections:
- Line 1: Add import
import { useSearchParams } from 'react-router-dom' - Lines 158-198: Simplify query param logic
Before:
const hash = window.location.hash || ''
const [rawHashBase, queryString] = hash.split('?')
const params = new URLSearchParams(queryString || '')
const currentPath = params.get('path')
// ... update with window.history.replaceStateAfter:
const [searchParams, setSearchParams] = useSearchParams()
// Read
const currentPath = searchParams.get('path')
// Write
setSearchParams({ path: selectedFile.path })Implementation Steps
Step 1: Install Dependencies
npm install react-router-dom@^6.28.0 -w apps/adminStep 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
handleNavigateto usenavigate() - 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.mdto#/docs/preview - Result:
#/docs/preview?path=README.md❌ (param incorrectly persists)
After migration:
- Navigate from
/docs/editor?path=README.mdto/docs/preview - Result:
/docs/preview✅ (React Router doesn't preserve params across routes by default)
The bug is fixed automatically because:
- Each route manages its own query params independently
- Only
/docs/editoruses the?path=param (viauseSearchParamsin SelfHostedDocsEditor) - Navigating to
/docs/previewor/docs/externalcreates 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:
<Route path="/analytics" element={<Navigate to="/" replace />} />
<Route path="/merchants" element={<Navigate to="/" replace />} />3. Docs Folder Auto-Expand
Update to use location.pathname:
const [expandedSections, setExpandedSections] = useState(() => {
return location.pathname.startsWith('/docs') ? { docs: true } : {}
})4. Email Action Links
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:
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/editorhttp://localhost:3001/#/users→/users
Verification
Pre-Flight Checks
# Install dependencies
npm install react-router-dom@^6.28.0 -w apps/admin
# Build and validate
npm run validate -w apps/adminManual 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
/analyticsor/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:
npm run dev -w apps/admin
# Test at http://localhost:3001Automated Testing
# Run tests
npm test -w apps/admin
# Run full validation
npm run validate -w apps/adminRollback Plan
If issues arise, revert the migration:
- Revert package.json: Remove
react-router-domdependency - Restore hash-based routing: Revert AdminDashboard.jsx, App.jsx, SelfHostedDocsEditor.jsx
- 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/editorinstead 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