KahWee

Thoughts on web development, programming, and technology

Migrating from Remix to React Router v7

Last weekend, I made the decision to migrate one of my full-stack React applications from Remix to React Router v7 framework mode. The migration took about two days and went surprisingly smooth - here's why I made the switch, what the process entailed, and the practical insights that made it successful.

Why React Router v7?

React Router v7 is essentially Remix v3 renamed. Ryan Florence and Michael Jackson merged the projects because "Remix v2 had become such a thin wrapper around React Router that an artificial separation developed between the two projects."

The practical benefits are immediate: instead of juggling @remix-run/node, @remix-run/react, @remix-run/serve, and others, everything consolidates into the unified react-router package. My dependencies dropped from 16 to 3. The new react-router typegen command eliminates manual type annotations in loader functions. The Vite-based build system is cleaner than Remix's custom setup.

The Toughest Part: TypeScript Type System Overhaul

While the API compatibility made most changes straightforward, TypeScript integration was by far the most challenging aspect of the migration. The git log reveals multiple commits dedicated to resolving type issues:

  • LoaderFunctionArgs and useLoaderData typing: Every route needed manual type updates
  • Route module type safety: New type patterns required learning React Router v7's approach
  • Interface mismatches: Database query results needed type alignment with component expectations
  • Case-sensitivity bugs: Database queries failed due to case-sensitive matching that worked in Remix

Multiple commits were dedicated to resolving TypeScript errors, indicating hours of type debugging across the migration.

This taught me that React Router v7's type system is more strict than Remix v2, which is ultimately beneficial but creates friction during migration.

Migration Overview: What Actually Changed

The migration touched 40 files with 327 insertions and 918 deletions - a net reduction of 591 lines. Here's the breakdown:

Package Dependencies

- "@remix-run/node": "^2.17.0"
- "@remix-run/react": "^2.17.0"
- "@remix-run/serve": "^2.17.0"
- "@remix-run/dev": "^2.17.0"
+ "@react-router/node": "^7.8.2"
+ "@react-router/serve": "^7.8.2"
+ "@react-router/dev": "^7.8.2"
+ "react-router": "^7.8.2"
+ "react-router-dom": "^7.8.2"

Build Scripts

- "build": "remix vite:build"
- "dev": "remix vite:dev"
- "start": "remix-serve ./build/server/index.js"
+ "build": "react-router build"
+ "dev": "react-router dev"
+ "start": "react-router-serve ./build/server/index.js"
+ "typecheck": "react-router typegen && tsc"

Vite Configuration

The Vite config transformation was dramatic:

- import { vitePlugin as remix } from '@remix-run/dev';
- // Complex remix configuration with future flags
- remix({
-   future: {
-     v3_fetcherPersist: true,
-     v3_relativeSplatPath: true,
-     v3_throwAbortReason: true,
-     v3_singleFetch: true,
-     v3_lazyRouteDiscovery: true,
-   }
- })
+ import { reactRouter } from '@react-router/dev/vite';
+ // Simple, clean configuration
+ plugins: [reactRouter(), tsconfigPaths(), tailwindcss()]

Import Updates Across Components

Every route and component needed import updates:

- import type { LoaderFunctionArgs } from '@remix-run/node';
- import { useLoaderData } from '@remix-run/react';
+ import type { LoaderFunctionArgs } from 'react-router';
+ import { useLoaderData } from 'react-router';

The Step-by-Step Migration Process

Phase 1: Dependencies and Configuration (30 minutes)

  1. Package.json overhaul: Replaced all @remix-run/* packages with React Router equivalents
  2. Script updates: Changed build/dev commands to use react-router CLI
  3. Vite configuration: Simplified from complex Remix config to minimal React Router setup
  4. New config file: Added react-router.config.ts for framework-specific settings

Phase 2: Import Refactoring (90 minutes)

This was the most time-consuming phase. Every file importing from @remix-run/* needed updates:

  • Route files (23 files): Updated loader/action imports and useLoaderData calls
  • Component files (8 files): Changed Link and Form imports
  • Entry files (2 files): Updated server/client entry points
  • Utility files (7 files): Modified auth and theme utilities

The process was methodical: start with entry points, then routes, then components, fixing TypeScript errors as they appeared.

Phase 3: Configuration Fine-tuning (30 minutes)

  • TypeScript config: Updated to use React Router types
  • Route file naming: Converted splat routes from api.auth.$.ts to api.auth.$rest.tsx for clarity
  • Development scripts: Added the new react-router typegen command

Understanding React Router Typegen: The Game Changer

One feature that took me time to fully appreciate was react-router typegen. This wasn't available in Remix - it's a React Router v7 innovation that represents a significant leap in TypeScript integration.

What React Router Typegen Does

The typegen command generates route-specific TypeScript types automatically:

react-router typegen
# Generates types in .react-router/types/ directory

It creates a +types/<route-file>.d.ts for each route, providing:

  • Automatic type inference for loader data without manual interfaces
  • Route-specific parameter typing (URL params, search params)
  • Action and loader return type validation
  • Component prop type safety based on your actual route implementation

The Rationale Behind Typegen

React Router's type generation executes your route config (app/routes.ts) to determine routes, then generates corresponding TypeScript definitions. This build-time analysis provides runtime safety without polluting the API design.

Key insight: Instead of manually defining LoaderData interfaces for every route, typegen infers them from your actual loader implementations. This eliminates the common Remix pattern of:

// Old Remix pattern - manual interface definition
interface LoaderData {
  recipes: Recipe[];
  user: User | null;
}

export async function loader(): Promise<LoaderData> {
  // ...
}

With React Router v7, you simply write:

// New React Router v7 pattern - types inferred automatically
export async function loader({ request }: LoaderFunctionArgs) {
  // Return whatever you want, typegen handles the interface
  return { recipes: await getRecipes(), user: await getUser(request) };
}

// Component gets proper typing automatically
export default function RecipeList() {
  const { recipes, user } = useLoaderData<typeof loader>();
  // recipes and user are fully typed without manual interfaces
}

The type inference approach fixes the interface drift problem that plagued my Remix codebase. With manual interfaces, you change a loader to return additional data but forget to update the interface. TypeScript doesn't complain because the interface still "works" - it just silently ignores the new properties.

useLoaderData<typeof loader> eliminates this entirely. The type reflects your actual loader implementation, not what you think it should return. When you refactor a loader's return structure, every component using that data updates automatically.

Here's where this really helps:

export async function loader({ request }: LoaderFunctionArgs) {
  const user = await getAuthUser(request);

  if (!user) {
    return { recipes: [], user: null, requiresAuth: true };
  }

  const recipes = await getUserRecipes(user.id);
  return { recipes, user, requiresAuth: false, totalCount: recipes.length };
}

export default function RecipePage() {
  const data = useLoaderData<typeof loader>();
  // TypeScript knows data.requiresAuth is boolean
  // data.totalCount only exists when user is present
  // data.user is exactly User | null based on the conditional logic
}

The manual interface approach would require defining a union type or optional properties, then keeping that interface in sync with the loader logic. With type inference, your implementation is the contract.

What Happened to Remix?

Ryan Florence and Michael Jackson handed React Router v7 to an open governance committee while they focus on Remix v3. But Remix v3 isn't an iteration - it's a complete rewrite with no React dependencies. They're building on a fork of Preact for "AI-first development" and "AI driven user interfaces." This leaves React Router v7 as the clear successor for production React applications that want the Remix experience.

Migration Notes

The API compatibility made most changes straightforward - loaders, actions, and components work identically. The migration was primarily changing imports and build configuration.

Some gotchas:

  • Route files with .ts extensions needed to become .tsx for React Router v7 to recognize them
  • The entry.server.tsx got much simpler - React Router v7 removed the bot/browser request splitting logic
  • Bundle size dropped ~30% and build times improved with the unified package structure

AI assistance was critical for batch import updates across 40+ files. What could have been a full day of manual work became two focused sessions.

Worth the Switch?

React Router v7 is actively developed while Remix v2 is in maintenance mode. The simplified dependency tree, better TypeScript integration, and improved build system provide immediate benefits. For teams on Remix v2 with future flags enabled, the migration is straightforward.

Whether coming from Remix or considering alternatives to Next.js, React Router v7 framework mode handles full-stack React applications well.


The migration is complete and running smoothly on React Router v7. The process has proven that React Router v7 framework mode is a worthy successor to Remix, offering the same power with simplified tooling and enhanced type safety.

All Tags