Used
How toRedirect Based on Screen Size in React Router
When building responsive layouts, you may want certain routes to behave differently depending on the viewport. For example, imagine you have a /settings
page:
- On mobile:
/settings
shows a list of navigation links to individual setting pages. - On desktop:
/settings
should immediately redirect to the first settings page because showing only the sidebar is poor UX.
We could check the viewport in the layout’s clientLoader
, but that means parsing the URL manually and redirecting regardless of the matched route. A cleaner approach is to create a dedicated index route that matches only /settings
and decides whether to render or redirect based on screen size. This lets us:
- Run the redirect before render to avoid a jarring flash.
- Avoid regex hacks by letting routing match exactly when we want.
- Stay responsive to viewport changes by listening for media query changes.
Defining the Routes
We start by defining the route tree so that /settings
has an index route (settings._index.tsx
) and a parameterized detail route (settings.$id.tsx
).
import { type RouteConfig, index, route } from "@react-router/dev/routes"; export default [ route("settings", "routes/settings.tsx", [ index("routes/settings._index.tsx"), route(":id", "routes/settings.$id.tsx"), ]), ] satisfies RouteConfig;
This structure ensures that the index route matches only /settings
, while /settings/:id
matches the detail view.
The Layout Component
The layout shows the settings navigation and renders the active child route in an <Outlet>
.
import { Outlet, Link, href } from "react-router"; import type { Route } from "./+types/settings"; export function loader() { return { options: [ { to: href("/settings/:id", { id: "1" }), label: "Settings 1" }, { to: href("/settings/:id", { id: "2" }), label: "Settings 2" }, { to: href("/settings/:id", { id: "3" }), label: "Settings 3" }, { to: href("/settings/:id", { id: "4" }), label: "Settings 4" }, ], }; } export default function Component({ loaderData }: Route.ComponentProps) { return ( <div> <ul> {loaderData.options.map((option) => ( <li key={option.to}> <Link to={option.to}>{option.label}</Link> </li> ))} </ul> <hr /> <Outlet /> </div> ); }
On mobile, this list is shown when visiting /settings
. On desktop, we’ll redirect away from this view entirely.
The Detail Route
The detail route simply displays the setting ID.
import type { Route } from "./+types/settings.$id"; export default function Component({ params }: Route.ComponentProps) { return <h1>Setting {params.id}</h1>; }
This is where desktop users will land by default when they try to visit /settings
.
The Index Route with Redirect Logic
This is the core of the solution. The clientLoader
runs before rendering the page:
- If the screen is mobile-sized (
max-width: 720px
), it returns amediaQuery
object so we can keep listening for changes. - If desktop, it immediately redirects to
/settings/1
.
The component also listens for viewport changes — if the user switches from mobile to desktop, it navigates away.
import { useLayoutEffect } from "react"; import { href, redirect, useNavigate } from "react-router"; import type { Route } from "./+types/settings._index"; export async function clientLoader() { let mediaQuery = window.matchMedia("(max-width: 720px)"); if (mediaQuery.matches) return { mediaQuery }; return redirect(href("/settings/:id", { id: "1" })); } export default function Component({ loaderData }: Route.ComponentProps) { let navigate = useNavigate(); useLayoutEffect(() => { loaderData.mediaQuery.addEventListener("change", listener); return () => loaderData.mediaQuery.removeEventListener("change", listener); function listener(event: MediaQueryListEvent) { if (event.matches) return; navigate(href("/settings/:id", { id: "1" })); } }, [navigate, loaderData.mediaQuery]); return null; }
This way, the redirect happens before any rendering on desktop, preventing flashes, and the experience adapts live if the user resizes their browser.