Skip to content

feat: migrate from React Router to Next.js App Router with proper RSC routes#1734

Closed
KevinWu098 wants to merge 8 commits into
mainfrom
kwu/nextjs-rsc-routing-migration-45e3
Closed

feat: migrate from React Router to Next.js App Router with proper RSC routes#1734
KevinWu098 wants to merge 8 commits into
mainfrom
kwu/nextjs-rsc-routing-migration-45e3

Conversation

@KevinWu098
Copy link
Copy Markdown
Member

Summary

Complete migration from the React Router "SSR shell around SPA" pattern to idiomatic Next.js App Router with proper route segments and React Server Components.

Architecture Changes

Before: Single catch-all [[...slug]]/page.tsxdynamic(() => import('../../App'), { ssr: false }) → React Router createBrowserRouter handling all navigation client-side.

After: Proper Next.js App Router segments with RSC pages, shared layouts, and next/navigation for all routing.

Route Structure

URL Before After
/ [[...slug]] → React Router / → Home app/(home)/page.tsx RSC + HomeShell client layout
/added [[...slug]] → React Router /:tab → Home app/(home)/added/page.tsx RSC + shared layout
/map [[...slug]] → React Router /:tab → Home app/(home)/map/page.tsx RSC + shared layout
/feedback React Router → window.location.replace() next.config.mjs redirect (server-side, no JS)
/auth [[...slug]] → React Router → AuthPage app/auth/page.tsx with client component
/auth/native Same as above app/auth/native/page.tsx
/unsubscribe/:userId [[...slug]] → React Router app/unsubscribe/[userId]/page.tsx with RSC params
/outage Separate React Router tree app/outage/page.tsx with proxy.ts guard
/* (unknown) React Router Navigate to / Next.js not-found.tsx

Key Improvements

  • react-router-dom completely removed — zero client router overhead
  • Per-route metadata — each page exports its own Metadata for proper SEO
  • Per-route SEO content — targeted sr-only content per route instead of one blob
  • Proper error handlingerror.tsx with reset() capability replaces errorElement
  • Server-side redirects/feedback redirect handled at edge (no client JS)
  • Shared layout persistence — tab switching within (home) group doesn't remount the app
  • External links use <a> — no more Link component for external URLs (RateMyProfessors, bug reports)
  • Title template — child pages can override the title with %s | AntAlmanac pattern
  • Sitemap updated — includes /added and /map routes

Provider Architecture

RootLayout (RSC)
└── Providers ('use client')
    ├── AppRouterCacheProvider (MUI Emotion)
    ├── AppThemeProvider (MUI theme)
    ├── AppPostHogProvider (analytics)
    └── AppQueryProvider (React Query)
        └── (home)/layout (RSC)
            └── HomeShell ('use client')
                ├── TourProvider (onboarding)
                ├── LocalizationProvider (date pickers)
                ├── Header + Tabs + Content
                └── Dialogs/Snackbars

Tab System

The tab system now uses usePathname() from next/navigation instead of useParams() from React Router. Tab clicks use next/link for navigation, and the ScheduleManagement component derives the active tab from the URL pathname. Since the HomeShell lives in the layout, it persists across tab navigation — no state loss.

Files Changed

  • Deleted: App.tsx, App.css, routes/ directory, [[...slug]]/ directory
  • Created: app/(home)/, app/auth/, app/unsubscribe/, app/outage/, app/error.tsx, app/not-found.tsx, app/providers.tsx
  • Modified: All components that imported from react-router-dom (14 files)

Test Plan

  • ✅ TypeScript compilation passes (no new errors; pre-existing errors from missing generated data unchanged)
  • oxlint --deny-warnings passes across all 244 source files
  • ✅ Next.js build compiles successfully (pre-existing TS strictness in scripts/ is the only blocker to full next build)
  • ✅ All non-data-dependent tests pass (locations, custom-events, schedule-id-race-condition)
  • ⚠️ Tests depending on generated term data (calendarize-helpers, parse-days-string, download-ics, patch-notes, schedule) require pnpm get-data to run — this is pre-existing and unrelated to this PR

Issues

Future Followup

  • TRPC + React Query integration: The vanilla createTRPCProxyClient can be upgraded to @trpc/react-query for hook-based data fetching with caching, now that the React Query provider is properly positioned
  • RSC data loading: TRPC procedures that fetch public data (grades, enrollment history) could be called from RSCs using a server-side TRPC caller
  • Route-level code splitting: Now that routes are proper Next.js segments, each page auto-gets its own chunk
  • Streaming/Suspense: The map (lazy-loaded) and search results could leverage React Suspense boundaries with streaming for better perceived performance
Open in Web Open in Cursor 

… routes

- Remove react-router-dom dependency entirely
- Replace catch-all [[...slug]] with proper route segments:
  - (home)/ group for /, /added, /map with shared layout
  - /auth and /auth/native as separate pages
  - /unsubscribe/[userId] as a dynamic route
  - /feedback handled via next.config.mjs redirect
- Create HomeShell client component (replaces App.tsx + Home.tsx)
  - Layout persists across tab navigation (no state loss)
  - Uses usePathname() for tab state sync instead of React Router useParams
- Create providers.tsx for root-level context providers
- Add per-route metadata exports for proper SEO
- Add per-route server-rendered SEO content (sr-only)
- Add error.tsx and not-found.tsx for proper error handling
- Replace all react-router-dom Link with next/link or <a> for external URLs
- Replace useNavigate with useRouter from next/navigation
- Replace useSearchParams/useParams with next/navigation equivalents
- Update sitemap.ts to include /added and /map routes
- Use title template in root layout metadata
- Add Next.js middleware with OUTAGE flag for maintenance mode
- Move OutagePage to proper /outage route under app/
- Remove legacy routes/ directory (all routes now in app/)
- Remove old ErrorPage.tsx (replaced by app/error.tsx)
Next.js 16 uses proxy.ts instead of middleware.ts. Integrate the
outage mode redirect into the existing proxy.ts CORS handler.
Toggle OUTAGE constant to enable maintenance mode.
The routes/ directory has been removed; all routes now live under app/.
PostHog's constructor accesses `self` which is unavailable during
Next.js static page generation. Previously this was hidden behind
`dynamic(import, { ssr: false })`.

- Lazily initialize PostHog instance on first client-side access
- Export a no-op Proxy during SSR so direct imports don't crash
- Guard PostHogProvider rendering with `typeof window` check
The Zustand store initializer for useThemeStore accessed
window.localStorage and window.matchMedia during module evaluation.
Add typeof window checks to provide safe defaults during SSR/static
generation.
Comprehensive fix for SSR/static generation compatibility. All Zustand
store initializers and module-level code that accessed window,
localStorage, or matchMedia now check typeof window !== 'undefined'
first, providing safe defaults during server-side rendering.

Fixed modules:
- SessionStore.ts: window.localStorage.removeItem in initializer
- ColumnStore.ts: getLocalStorageColumnToggles() at module level
- CoursePaneStore.ts: window.location.search in paramsAreInURL()
- PatchNotesStore.ts: shouldShowPatchNotes() calling localStorage/matchMedia
- RightPaneStore.ts: window.location.search in constructor
- TutorialHelpers.tsx: window.matchMedia in tourShouldRun()
- plannerHelpers.ts: window.location.search in shouldSearchPlannerFromParams()
- Wrap PostHogPageviewTracker in <Suspense> (uses useSearchParams)
- Mark all (home) pages as force-dynamic since the interactive app
  shell uses useSearchParams in multiple components and cannot be
  meaningfully statically generated
- Mark auth and unsubscribe pages as force-dynamic (depend on query params)

Next.js requires useSearchParams() to be inside a Suspense boundary
during static generation. Since the home pages are fundamentally
interactive SPA content, dynamic rendering is the correct choice.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants