@remix-run/router vs react-router vs wouter
Client-Side Routing Solutions for React Applications
@remix-run/routerreact-routerwouterSimilar Packages:

Client-Side Routing Solutions for React Applications

@remix-run/router, react-router, and wouter are all JavaScript libraries that provide client-side routing capabilities for web applications, primarily in React ecosystems. @remix-run/router is the underlying router engine extracted from React Router v6, offering a framework-agnostic core for managing navigation, history, and data loading. react-router is the full-featured, widely adopted routing library for React that builds on top of @remix-run/router to deliver declarative route configuration, navigation hooks, and UI components. wouter is a minimal, lightweight alternative focused on simplicity and small bundle size, using hooks as its primary API and avoiding complex abstractions.

Npm Package Weekly Downloads Trend

3 Years

Github Stars Ranking

Stat Detail

Package
Downloads
Stars
Size
Issues
Publish
License
@remix-run/router056,2812.76 MB1502 months agoMIT
react-router056,2814.18 MB15010 days agoMIT
wouter07,76574.8 kB293 months agoUnlicense

Client-Side Routing in React: @remix-run/router vs react-router vs wouter

Routing is foundational to modern web apps, but not all routers serve the same purpose. While react-router dominates the React ecosystem, its underlying engine @remix-run/router powers it, and minimalist alternatives like wouter offer a different trade-off. Let’s compare how they handle real-world routing tasks.

🧱 Core Architecture: Engine vs Framework vs Micro-Library

@remix-run/router is a router engine, not a React library. It handles URL parsing, history management, matching, and data loading logic—but has no React components or hooks. You’d use it to build your own router.

// @remix-run/router: low-level usage
import { createRouter } from "@remix-run/router";

const router = createRouter({
  routes: [{ path: "/", loader: () => fetch("/data") }],
  history: /* your history instance */
});

router.subscribe(state => {
  // Manually update your app based on router state
});
router.initialize();

react-router is the full React integration built on top of @remix-run/router. It provides <Routes>, <Route>, useNavigate(), and other React-friendly APIs.

// react-router: standard React usage
import { createBrowserRouter, RouterProvider } from "react-router-dom";

const router = createBrowserRouter([
  { path: "/", element: <Home />, loader: () => fetch("/data") }
]);

function App() {
  return <RouterProvider router={router} />;
}

wouter is a micro-library (~1.5kB) that uses hooks and context internally but exposes only a few simple hooks. It avoids render props and complex configuration.

// wouter: minimal hook-based routing
import { useLocation, useNavigate } from "wouter";

function App() {
  const [location] = useLocation();
  const navigate = useNavigate();

  return (
    <div>
      {location === "/" ? <Home /> : <NotFound />}
      <button onClick={() => navigate("/")}>Go Home</button>
    </div>
  );
}

🗺️ Route Definition: Declarative vs Programmatic vs Manual

react-router uses declarative route objects (or JSX elements in older versions). Routes can be nested, and each can define loaders, actions, and error boundaries.

// react-router: nested routes with data loading
const routes = [
  {
    path: "/users",
    element: <UsersLayout />,
    children: [
      { index: true, element: <UserList /> },
      { path: ":id", element: <UserProfile />, loader: loadUser }
    ]
  }
];

@remix-run/router requires you to manually wire up route matching and rendering. There’s no <Route> component—just raw route configs and state subscriptions.

// @remix-run/router: no built-in rendering
const routes = [{ path: "/users/:id", id: "user", loader: loadUser }];
const router = createRouter({ routes, history });

// You must interpret router.state.matches yourself
// and render the correct components based on match IDs

wouter doesn’t have a route definition system at all. You manually check the current path and render components conditionally.

// wouter: no route config—just conditional rendering
function App() {
  const [location] = useLocation();

  if (location === "/") return <Home />;
  if (location.startsWith("/users/")) {
    const id = location.split("/")[2];
    return <UserProfile id={id} />;
  }
  return <NotFound />;
}

🔁 Navigation and Hooks

All three support programmatic navigation, but with different ergonomics.

react-router provides useNavigate() and useParams():

import { useNavigate, useParams } from "react-router-dom";

function UserProfile() {
  const { id } = useParams();
  const navigate = useNavigate();

  return <button onClick={() => navigate("/users")}>Back</button>;
}

wouter offers similar hooks:

import { useNavigate, useParams } from "wouter";

function UserProfile() {
  const params = useParams(); // returns { id: "123" }
  const navigate = useNavigate();

  return <button onClick={() => navigate("/users")}>Back</button>;
}

@remix-run/router has no hooks—it’s framework-agnostic. You call router.navigate("/path") directly on the router instance.

// @remix-run/router: imperative navigation
router.navigate("/users/123");

📥 Data Loading and Transitions

react-router supports route-level data loading via loader functions and gives you transition states via useNavigation():

// react-router: loader + pending UI
import { useLoaderData, useNavigation } from "react-router-dom";

export async function loader({ params }) {
  return await fetch(`/api/users/${params.id}`).then(r => r.json());
}

function UserProfile() {
  const user = useLoaderData();
  const navigation = useNavigation();

  if (navigation.state === "loading") return <Spinner />;
  return <div>{user.name}</div>;
}

@remix-run/router also supports loaders, but you must manually track loading states from the router’s subscription:

// @remix-run/router: manual loader handling
router.subscribe(state => {
  if (state.revalidation === "loading") {
    // Show loading UI
  }
  // Render data from state.loaderData
});

wouter has no built-in data loading. You manage data fetching with useEffect or other patterns:

// wouter: manual data fetching
import { useEffect, useState } from "react";
import { useParams } from "wouter";

function UserProfile() {
  const { id } = useParams();
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetch(`/api/users/${id}`).then(r => r.json()).then(setUser);
  }, [id]);

  return user ? <div>{user.name}</div> : <Spinner />;
}

📦 Bundle Size and Dependencies

  • react-router includes @remix-run/router as a dependency and adds React-specific layers. It’s larger but feature-rich.
  • @remix-run/router is standalone and has no dependencies—it’s the leanest core router.
  • wouter is the smallest overall (~1.5kB min+gzip) and has zero dependencies.

However, size isn’t everything. If your app needs nested routes, data loaders, or error boundaries, wouter will force you to rebuild those concepts manually—eroding its size advantage.

🔄 When to Use Which?

Use react-router if:

  • You’re building a standard React app
  • You need nested routes, data loading, or pending states
  • You value convention and long-term maintainability

Use @remix-run/router if:

  • You’re building a framework or library that needs routing logic
  • You’re integrating routing into a non-React view layer
  • You need direct access to the router’s internal state machine

Use wouter if:

  • Your app has 2–5 simple routes
  • Bundle size is a hard constraint
  • You don’t need data loading abstractions or route nesting

⚠️ Common Pitfalls

  • Don’t use @remix-run/router directly in a React app unless you’re prepared to rebuild all the React integrations (RouterProvider, hooks, etc.). You’ll end up re-implementing react-router.
  • Don’t choose wouter for growing apps—migrating to react-router later means rewriting all your routing logic.
  • Don’t assume wouter supports dynamic route matching out of the box—it does, but you must parse params manually or use its optional pattern matcher.

💡 Final Thought

These tools exist on a spectrum: @remix-run/router is the engine, react-router is the car, and wouter is the bicycle. Choose based on how far you’re going and how much cargo you’re carrying—not just how fast you leave the garage.

How to Choose: @remix-run/router vs react-router vs wouter

  • @remix-run/router:

    Choose @remix-run/router if you're building a custom routing layer or integrating routing into a non-React framework (like Vue or Svelte) and need fine-grained control over the router's internal behavior. It’s ideal for library authors or teams creating opinionated frameworks who want to reuse React Router’s robust routing logic without its React-specific bindings. However, for standard React apps, you’ll almost always want to use react-router instead, as it provides the necessary React integrations out of the box.

  • react-router:

    Choose react-router for production React applications that require a mature, feature-complete routing solution with strong community support, nested routes, data loading patterns, and seamless integration with React’s component model. It’s the go-to choice when you need reliability, extensive documentation, and advanced capabilities like route nesting, pending states, and error boundaries — especially in medium to large-scale applications where maintainability and convention matter.

  • wouter:

    Choose wouter when you need a tiny, dependency-free router for a small React project or prototype where bundle size is critical and advanced features like nested routes or data loaders aren’t required. It’s well-suited for lightweight SPAs, demos, or embedded widgets where simplicity and minimal overhead outweigh the need for a full routing ecosystem. Avoid it in complex applications that may eventually require migration to a more capable router.

README for @remix-run/router

Remix Router

The @remix-run/router package is a framework-agnostic routing package (sometimes referred to as a browser-emulator) that serves as the heart of React Router and Remix and provides all the core functionality for routing coupled with data loading and data mutations. It comes with built-in handling of errors, race-conditions, interruptions, cancellations, lazy-loading data, and much, much more.

If you're using React Router, you should never import anything directly from the @remix-run/router - you should have everything you need in react-router-dom (or react-router/react-router-native if you're not rendering in the browser). All of those packages should re-export everything you would otherwise need from @remix-run/router.

[!WARNING]

This router is a low-level package intended to be consumed by UI layer routing libraries. You should very likely not be using this package directly unless you are authoring a routing library such as react-router-dom or one of it's other UI ports.

API

A Router instance can be created using createRouter:

// Create and initialize a router.  "initialize" contains all side effects
// including history listeners and kicking off the initial data fetch
let router = createRouter({
  // Required properties
  routes: [{
    path: '/',
    loader: ({ request, params }) => { /* ... */ },
    children: [{
      path: 'home',
      loader: ({ request, params }) => { /* ... */ },
    }]
  },
  history: createBrowserHistory(),

  // Optional properties
  basename, // Base path
  mapRouteProperties, // Map framework-agnostic routes to framework-aware routes
  future, // Future flags
  hydrationData, // Hydration data if using server-side-rendering
}).initialize();

Internally, the Router represents the state in an object of the following format, which is available through router.state. You can also register a subscriber of the signature (state: RouterState) => void to execute when the state updates via router.subscribe();

interface RouterState {
  // False during the initial data load, true once we have our initial data
  initialized: boolean;
  // The `history` action of the most recently completed navigation
  historyAction: Action;
  // The current location of the router.  During a navigation this reflects
  // the "old" location and is updated upon completion of the navigation
  location: Location;
  // The current set of route matches
  matches: DataRouteMatch[];
  // The state of the current navigation
  navigation: Navigation;
  // The state of any in-progress router.revalidate() calls
  revalidation: RevalidationState;
  // Data from the loaders for the current matches
  loaderData: RouteData;
  // Data from the action for the current matches
  actionData: RouteData | null;
  // Errors thrown from loaders/actions for the current matches
  errors: RouteData | null;
  // Map of all active fetchers
  fetchers: Map<string, Fetcher>;
  // Scroll position to restore to for the active Location, false if we
  // should not restore, or null if we don't have a saved position
  // Note: must be enabled via router.enableScrollRestoration()
  restoreScrollPosition: number | false | null;
  // Proxied `preventScrollReset` value passed to router.navigate()
  preventScrollReset: boolean;
}

Navigations

All navigations are done through the router.navigate API which is overloaded to support different types of navigations:

// Link navigation (pushes onto the history stack by default)
router.navigate("/page");

// Link navigation (replacing the history stack)
router.navigate("/page", { replace: true });

// Pop navigation (moving backward/forward in the history stack)
router.navigate(-1);

// Form submission navigation
let formData = new FormData();
formData.append(key, value);
router.navigate("/page", {
  formMethod: "post",
  formData,
});

// Relative routing from a source routeId
router.navigate("../../somewhere", {
  fromRouteId: "active-route-id",
});

Fetchers

Fetchers are a mechanism to call loaders/actions without triggering a navigation, and are done through the router.fetch() API. All fetch calls require a unique key to identify the fetcher.

// Execute the loader for /page
router.fetch("key", "/page");

// Submit to the action for /page
let formData = new FormData();
formData.append(key, value);
router.fetch("key", "/page", {
  formMethod: "post",
  formData,
});

Revalidation

By default, active loaders will revalidate after any navigation or fetcher mutation. If you need to kick off a revalidation for other use-cases, you can use router.revalidate() to re-execute all active loaders.

Future Flags

We use Future Flags in the router to help us introduce breaking changes in an opt-in fashion ahead of major releases. Please check out the blog post and React Router Docs for more information on this process. The currently available future flags in @remix-run/router are:

FlagDescription
v7_normalizeFormMethodNormalize useNavigation().formMethod to be an uppercase HTTP Method
v7_prependBasenamePrepend the basename to incoming router.navigate/router.fetch paths