Frontend architecture
Replacing React Router With Wouter Without Breaking Auth Flows
What changed when I replaced the router across two React applications, and why the important work was preserving layouts, deep links, and post-login redirects.
In June 2025, I replaced React Router with Wouter in LockeBio’s EMR monorepo. The change covered the admin and provider applications and touched 60 files.
I expected most of the work to be changing imports. It was not.
The router was tied to layouts, authentication guards, resource pages, error boundaries, navigation links, and Vite configuration. Removing BrowserRouter was one line. Preserving what happened when someone opened a protected URL directly was the real migration.
Start with the route tree
The old applications used React Router route elements and nested outlets. Wouter uses Route, Switch, and explicit nesting.
This is a condensed excerpt from the migrated admin route tree. The component names are from the LockeBio implementation.
index.tsx
<Switch>
<Route path="/app" nest>
<DashboardLayout>
<Switch>
<Route path="/" component={Dashboard} />
{UserRoutes}
{OrganizationsRoutes}
{MedicationRoutes}
{EncounterRoutes}
{PatientRoutes}
</Switch>
</DashboardLayout>
</Route>
<Route path="/">
<AuthLayout>
<Switch>
<Route component={Login} />
</Switch>
</AuthLayout>
</Route>
<Route component={NotFound} />
</Switch>I moved the route tree before converting individual links. That exposed the structural differences first: how nested routes matched, where the authenticated layout started, and which switch owned the 404.
Once those decisions were visible, changing Link, useLocation, and route parameters was mostly mechanical.
Outlets became children
React Router’s Outlet allowed a layout to render the matching child route. With Wouter, the layouts received children directly.
This excerpt is condensed from the migrated admin layout:
index.tsx
export const DashboardLayout = ({ children }: DashboardLayoutProps) => {
return (
<PrivateRoute>
<LoadUser>
<Flex h="100vh">
<SideBarNavigation />
<Box flex={1} overflowY="auto" p="8">
<ErrorBoundedOutlet>{children}</ErrorBoundedOutlet>
</Box>
</Flex>
</LoadUser>
</PrivateRoute>
)
}The order matters. Authentication runs before loading the user. The application shell renders around the page. The error boundary stays around routed content instead of disappearing with the outlet.
That last detail is easy to miss because the happy path still renders without it.
Migrate repeated routes once
The admin application had the same route shape for many resources: list, edit, and create. That behavior already lived in a shared RouteFactory, so I migrated the factory instead of rewriting each resource separately.
The migrated factory built a small configuration and rendered the available components:
index.tsx
const config: ElemTuple[] = [
[List, '/'],
[Edit, '/:id'],
[Create, '/create'],
]
const components = config.filter(([component]) =>
Boolean(component),
) as ComponentTuple[]
return (
<Route path={resource} nest>
<Switch>
{components.map(([component, path]) => (
<Route
key={`${resource}-path-${path}`}
path={path}
component={component}
/>
))}
</Switch>
</Route>
)The factory only owned the repeated route mechanics. Each feature still supplied its own list, edit, and create pages.
This gave the migration a useful boundary: fix the shared pattern once, then verify the unusual routes individually.
The auth problem showed up later
The EMR router migration preserved the authentication behavior that existed at the time. In March 2026, in LockeBio’s main product repository, I fixed a related problem in the shared Wouter guards.
If a signed-out user opened a protected page, the login flow sent them to the default dashboard afterward. The requested page was lost.
The fix was to carry the pathname and query string through login. The shared utility used in that change was ReturnTo:
index.ts
export const ReturnTo = {
isValid(path?: string | null): boolean {
return typeof path === 'string' && path.startsWith('/')
},
create(pathname: string, search: string) {
return encodeURIComponent(`${pathname}${search}`)
},
get(searchParams: URLSearchParams) {
const returnTo = searchParams.get('returnTo')
if (typeof returnTo !== 'string' || !ReturnTo.isValid(returnTo)) {
return undefined
}
return returnTo
},
}The private guard created the value before redirecting to login:
index.tsx
const returnTo = ReturnTo.create(
window.location.pathname,
window.location.search,
)
const redirectUrl = `${navigateTo}?returnTo=${returnTo}`
return <Redirect to={redirectUrl} />The public guard restored it after authentication:
index.tsx
const redirectTo = ReturnTo.get(searchParams) ?? navigateTo
return <Redirect to={redirectTo} />Keeping the query string was important. A filter or selected view can be part of the page the user intended to open.
The implementation accepts return values beginning with /. In a system where that value can reach an external redirect, I would make the check stricter and reject protocol-relative values beginning with // as well.
Test from outside the application
Clicking every sidebar link is not enough. Client-side navigation can work while direct requests, refreshes, or authentication handoffs fail.
This is the matrix I use for the important paths:
| Starting state | Action | Expected result |
|---|---|---|
| Signed out | Open the login page | Login renders without the dashboard shell |
| Signed out | Open a protected list directly | Redirects to login with a return destination |
| Signed out | Open a protected detail page with query parameters | The full path and query string are preserved |
| Signed in | Complete login with a return destination | Returns to the requested page |
| Signed in | Open the login page | Redirects to the application |
| Signed in | Refresh a nested route | The same screen renders |
| Signed in | Open an unknown route | The intended 404 renders |
| Signed in | Trigger a routed rendering error | The route-level fallback renders inside the shell |
I would run it against both applications. They shared routing infrastructure, but they had different route trees and users.
Three rules came out of this work:
- Write down the URLs that must survive before changing the router.
- Migrate layouts, guards, and shared route factories before leaf pages.
- Test deep links from the address bar while signed in and signed out.
The dependency swap was small. The job was preserving the promises already attached to each URL.