Migrating from Remix to React Router v7: Testing Your Migration
Migrating a Remix app to React Router v7 involves import path changes, API renames, and route config restructuring. The safest migration strategy writes tests against your Remix app first, then runs the same tests after migration to catch regressions. This post walks through the full test-first migration workflow.
Key Takeaways
Write tests before you touch a single import. A test suite written against the Remix app defines the contract. After migration, all the same tests must pass — that's how you know nothing broke.
createRemixStub no longer exists — update your test imports first. The most immediate breaking change in test files is the import path: @remix-run/testing → react-router, and createRemixStub → createRoutesStub.
E2E tests are your migration safety net. Unit tests verify logic; E2E tests verify that the assembled app actually works in a browser after all the package changes land.
What Actually Changed
When Remix and React Router merged into React Router v7, the surface area change was larger than a typical minor version bump. Here's what moved:
Package Imports
// Before (Remix)
import { json, redirect, useLoaderData } from "@remix-run/react";
import { type LoaderFunctionArgs } from "@remix-run/node";
import { createRemixStub } from "@remix-run/testing";
// After (React Router v7)
import { json, redirect, useLoaderData } from "react-router";
import { type LoaderFunctionArgs } from "react-router";
import { createRoutesStub } from "react-router";Route Configuration
Remix used a file-based convention with a remix.config.js. React Router v7 uses an explicit routes.ts file:
// app/routes.ts (new in v7)
import { type RouteConfig, route, index } from "@react-router/dev/routes";
export default [
index("routes/home.tsx"),
route("about", "routes/about.tsx"),
route("dashboard", "routes/dashboard.tsx", [
index("routes/dashboard/overview.tsx"),
route("settings", "routes/dashboard/settings.tsx"),
]),
] satisfies RouteConfig;handle Exports Removed
Remix allowed route modules to export a handle object for custom metadata. React Router v7 removed this pattern. If your tests assert on handle exports, those tests need to be deleted.
Meta Function Signature Changed
// Before (Remix)
export function meta({ data }: { data: LoaderData }) {
return [{ title: data.title }];
}
// After (React Router v7)
import { type MetaFunction } from "react-router";
export const meta: MetaFunction<typeof loader> = ({ data }) => {
return [{ title: data?.title }];
};Step 1: Write Tests Against the Existing Remix App
Before changing a single import, write tests that document what your app does. These tests become your regression suite.
Start with the routes that touch the most users:
// tests/remix/auth-flow.test.ts (written BEFORE migration)
import { createRemixStub } from "@remix-run/testing";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { action as loginAction } from "~/routes/login";
import Login from "~/routes/login";
describe("login flow — pre-migration baseline", () => {
it("shows validation error for empty email", async () => {
const Stub = createRemixStub([
{
path: "/login",
Component: Login,
action: loginAction,
},
]);
render(<Stub initialEntries={["/login"]} />);
const submitButton = await screen.findByRole("button", { name: /sign in/i });
await userEvent.click(submitButton);
expect(await screen.findByText(/email is required/i)).toBeInTheDocument();
});
it("redirects to dashboard on valid credentials", async () => {
// ... test body
});
});Write similar baseline tests for:
- Every loader's data output
- Every action's validation rules
- Navigation between routes
- Error boundary rendering
- Protected route redirects
These tests will fail after you rename the imports — that's expected and intentional. They are your migration validation checklist.
Step 2: Create the Migration Test Checklist
Before running npm install react-router@7, document what you're changing. For each route file:
# Migration Checklist: routes/login.tsx
## Import changes
- [ ] `@remix-run/react` → `react-router`
- [ ] `@remix-run/node` → `react-router`
## API changes
- [ ] `json()` — same, no change
- [ ] `redirect()` — same, no change
- [ ] `useLoaderData()` — same, no change
- [ ] `useActionData()` — same, no change
## Exports to review
- [ ] `handle` export — REMOVE (not supported in v7)
- [ ] `meta` function — UPDATE signature
## Tests affected
- [ ] `login-validates-empty-email` — needs import update only
- [ ] `login-redirects-on-success` — needs import update only
- [ ] `login-handles-invalid-credentials` — needs import update onlyStep 3: Update Tests First, Then Code
The migration order matters. Update your test files to use the new imports before updating the route files. This way you always have a clear red/green signal.
// tests/routes/login.test.ts (updated)
// Changed: createRemixStub → createRoutesStub, import from react-router
import { createRoutesStub } from "react-router"; // was: @remix-run/testing
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { action as loginAction } from "~/routes/login";
import Login from "~/routes/login";
describe("login flow", () => {
it("shows validation error for empty email", async () => {
const Stub = createRoutesStub([
{
path: "/login",
Component: Login,
action: loginAction,
},
]);
render(<Stub initialEntries={["/login"]} />);
const submitButton = await screen.findByRole("button", { name: /sign in/i });
await userEvent.click(submitButton);
expect(await screen.findByText(/email is required/i)).toBeInTheDocument();
});
});Run these tests — they should fail because the source files still import from @remix-run/*. Now update the source files to make them pass.
Step 4: Migrate the Route Files
With tests failing (as expected), migrate the route modules one by one:
// app/routes/login.tsx — before
import { json, redirect } from "@remix-run/node";
import { useActionData, Form } from "@remix-run/react";
import type { ActionFunctionArgs } from "@remix-run/node";
export const handle = { title: "Login" }; // REMOVE THIS
// app/routes/login.tsx — after
import { json, redirect, useActionData, Form } from "react-router";
import type { ActionFunctionArgs } from "react-router";
// handle export removedAfter each file migration, run the associated tests:
npx vitest run tests/routes/login.test.tsGreen = the route migrated correctly. Red = something changed that your baseline didn't catch.
Step 5: Migrate the Route Config
The biggest structural change is moving from the Remix file-convention router to routes.ts:
// app/routes.ts
import { type RouteConfig, route, index, layout } from "@react-router/dev/routes";
export default [
index("routes/home.tsx"),
route("login", "routes/login.tsx"),
route("register", "routes/register.tsx"),
layout("routes/layout/dashboard.tsx", [
route("dashboard", "routes/dashboard/index.tsx"),
route("dashboard/settings", "routes/dashboard/settings.tsx"),
route("dashboard/team", "routes/dashboard/team.tsx"),
]),
route("*", "routes/catchall.tsx"),
] satisfies RouteConfig;Write a test that verifies the route structure by rendering the stub router and navigating:
// tests/navigation.test.tsx
import { createRoutesStub } from "react-router";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
describe("navigation after migration", () => {
it("navigates from home to dashboard", async () => {
const Stub = createRoutesStub([
{
path: "/",
Component: () => <a href="/dashboard">Go to Dashboard</a>,
},
{
path: "/dashboard",
Component: () => <h1>Dashboard</h1>,
loader: () => ({ user: { name: "Alice" } }),
},
]);
render(<Stub initialEntries={["/"]} />);
await userEvent.click(screen.getByText("Go to Dashboard"));
expect(await screen.findByRole("heading", { name: "Dashboard" })).toBeInTheDocument();
});
});Common Breaking Changes Your Tests Will Catch
1. Route file naming convention changed. Remix used dot-separator convention (dashboard.settings.tsx). React Router v7 with routes.ts is explicit — if your routes.ts doesn't include a route, it won't exist. Tests that navigate to a route that disappeared will catch this immediately.
2. Nested layout routes need wrapping. If you used Remix's automatic layout nesting, you need to explicitly use layout() in routes.ts. Missing a layout causes child routes to render without the shell. Your navigation tests will catch a missing layout.
3. json() is deprecated in v7. React Router v7 encourages returning plain objects from loaders instead of calling json(). The old json() still works but logs a deprecation warning. Tests comparing response.json() calls may need updating if you switch to plain object returns.
4. Error boundary props changed. The CatchBoundary export is gone. React Router v7 uses ErrorBoundary for all error cases. Tests that render CatchBoundary directly need to be rewritten.
// Before (Remix)
export function CatchBoundary() {
const caught = useCatch();
return <div>Caught: {caught.status}</div>;
}
// After (React Router v7)
export function ErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return <div>Error: {error.status}</div>;
}
return <div>Unknown error</div>;
}E2E Validation After Migration
Unit and integration tests verify individual functions and components. After the full migration, run a Playwright E2E suite to verify the assembled app works end-to-end:
// e2e/post-migration.spec.ts
import { test, expect } from "@playwright/test";
test.describe("post-migration smoke tests", () => {
test("home page loads and is interactive", async ({ page }) => {
await page.goto("/");
await expect(page).toHaveTitle(/helpmetest/i);
await expect(page.getByRole("navigation")).toBeVisible();
});
test("authentication flow completes", async ({ page }) => {
await page.goto("/login");
await page.fill('[name="email"]', process.env.TEST_USER_EMAIL!);
await page.fill('[name="password"]', process.env.TEST_USER_PASSWORD!);
await page.click('[type="submit"]');
await expect(page).toHaveURL("/dashboard");
});
test("protected route redirects unauthenticated users", async ({ page }) => {
await page.goto("/dashboard");
await expect(page).toHaveURL(/\/login/);
});
test("loader data appears in UI", async ({ page }) => {
// log in first
await page.goto("/dashboard");
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
});
});Run these tests against a staging build of your migrated app before deploying to production.
Migration Verification Checklist
Use this checklist to confirm your migration is complete and tested:
- All
@remix-run/*imports replaced withreact-router createRemixStubreplaced withcreateRoutesStubin all test fileshandleexports removed from all route filesCatchBoundaryconverted toErrorBoundary+isRouteErrorResponsemetafunction signatures updated to use typedMetaFunctionroutes.tscovers every route that existed in Remix config- Unit tests green for all loaders and actions
- Integration tests green for all route components
- E2E smoke tests pass against the migrated app
For continuous post-migration regression coverage, HelpMeTest runs your full Playwright test suite in the cloud on every deploy — catching any migration-related regressions that slip through before they hit users. Flat $100/month, unlimited tests.