Skip to content

Fix: React Router 7 Not Working — Framework Mode, Loaders, Type Safety, and Remix Migration

FixDevs · (Updated: )

Part of:  React & Frontend Errors

Quick Answer

How to fix React Router v7 errors — framework mode vs library mode setup, loader/action data type narrowing, route module exports missing, single-fetch revalidation, hydration mismatch, and Remix v2 migration paths.

The Error

You upgrade to React Router 7 and the Vite dev server won’t start:

[vite] Internal server error: Failed to resolve entry for package "@react-router/dev/vite"

Or your loader’s typed data shows up as unknown on the route component:

export async function loader() {
  return { user: { id: 1, name: "Alice" } };
}

export default function Profile() {
  const data = useLoaderData();  // unknown
  return <h1>{data.user.name}</h1>;  // Type error.
}

Or after an action POST the page doesn’t show the updated data:

// app/routes/posts.tsx
export async function action({ request }) {
  await createPost(await request.formData());
  return { ok: true };
}

export async function loader() {
  return { posts: await getPosts() };
}
// Submit form → action runs → loader doesn't re-run, posts list stale.

Or you migrated from Remix v2 and routes are 404:

GET /posts → 404 Not Found
# But app/routes/posts.tsx exists.

Why This Happens

React Router 7 merged Remix into the same package. There are now two modes:

  • Library mode — the classic react-router-dom style. Client-only routing, no server, no loaders.
  • Framework mode — the Remix-style. Vite plugin, file-based routes, loaders/actions, SSR. This is what most new projects want.

Most upgrade pain is the wrong mode, missing plugin config, or stale Remix imports:

  • @remix-run/... imports must become react-router or @react-router/....
  • react-router-dom is still there for library mode users but doesn’t enable framework features.
  • vite.config.ts needs @react-router/dev/vite for framework mode.
  • Type-safe data requires the +types/<route> import pattern (the codegen).

The “loader didn’t revalidate” issue is the new single-fetch behavior — actions revalidate by default, but specific revalidation control is opt-in.

A second layer of confusion is the type system. RR7 generates per-route type files in .react-router/types/ based on your route tree. These types include the loader return type, action return type, and the typed params object. If your editor still shows unknown after a clean install, three things to check: that you’ve run the dev server at least once, that tsconfig.json includes .react-router/types/**/* in the include array, and that @react-router/dev is actually in package.json rather than a peer dep coming from a hoisted node_modules.

The third trap is hydration mismatch. Framework mode streams HTML — the server renders a shell, sends it, then continues rendering the rest as Suspense boundaries resolve. If your code reads window, Date.now(), or any locale-sensitive value during the initial render, the server and client produce different HTML and React aborts the hydration. The new streaming model amplifies this: in pre-Remix-merge SPA mode you’d just see a flicker, but in RR7 framework mode a hydration mismatch can leave entire route trees blank because React falls back to client rendering and the streaming shell never completes.

Version History That Changes the Failure Mode

React Router’s version 7 release in November 2024 was the most disruptive change to the library since the React Router 4 → 5 transition. The relevant inflection points:

  • v6.4 (Sep 2022): Data APIs landed (loader, action, useLoaderData) but still in library mode only. Required wrapping in RouterProvider with a createBrowserRouter tree.
  • v6.28 (Oct 2024): Last v6 release before the merge. Marked the “future flags” (v7_startTransition, v7_relativeSplatPath, v7_fetcherPersist) that opt v6 codebases into v7 behavior incrementally. Enable these flags before upgrading — the v7 upgrade is far less painful when you’re already running with v7 semantics.
  • v7.0 (Nov 2024): Remix merged into React Router. @remix-run/* packages renamed to @react-router/*. Framework mode is now the primary delivery vehicle; library mode is preserved for incremental adopters.
  • v7.1 (early 2025): Type generation became automatic during react-router dev and react-router build. Earlier 7.0 betas required a separate react-router typegen step.
  • v7.2+ (mid 2025): React Server Components support began landing behind the unstable_rsc flag in react-router.config.ts. Hydration semantics shift again — clientLoader runs at different times depending on whether the route is RSC or client.
  • v7.5+ (late 2025): Codemod tooling from @react-router/codemod matured to cover most Remix v2 patterns automatically.

The “Module not found: @react-router/dev/vite” error is almost always a v7 install on a project that still has @remix-run/dev in package.json — drop the Remix packages entirely after the codemod runs.

Fix 1: Set Up Framework Mode

npm install react-router @react-router/dev @react-router/node @react-router/serve
// vite.config.ts
import { reactRouter } from "@react-router/dev/vite";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [reactRouter()],
});
// react-router.config.ts
import type { Config } from "@react-router/dev/config";

export default {
  // Server-side render by default.
  ssr: true,
} satisfies Config;

Routes live under app/routes/:

// app/routes/_index.tsx
export default function Home() {
  return <h1>Home</h1>;
}

// app/routes/posts.tsx
export async function loader() {
  return { posts: await getPosts() };
}

export default function Posts() {
  const { posts } = useLoaderData<typeof loader>();
  return <List items={posts} />;
}

Start with npm run dev (defined as react-router dev in package.json).

Pro Tip: For an existing React app you want to migrate, run npx create-react-router@latest in a sandbox to see the current package.json scripts and vite.config.ts. The templates change as RR matures.

Fix 2: Use the Generated Type Imports

For full type safety on loaders, actions, and route params, use the codegen +types/<route> import:

// app/routes/posts.$id.tsx
import type { Route } from "./+types/posts.$id";

export async function loader({ params }: Route.LoaderArgs) {
  const post = await getPost(params.id);  // params.id is typed as string
  return { post };
}

export default function PostPage({ loaderData }: Route.ComponentProps) {
  return <h1>{loaderData.post.title}</h1>;
}

Three pieces:

  • Route.LoaderArgs — typed args including params (matching the route’s $id segment).
  • Route.ActionArgs — typed args for actions.
  • Route.ComponentProps — props including loaderData, actionData, params.

The types are generated automatically. If they’re missing, run npm run dev (or react-router typegen) once to create +types/ files.

Common Mistake: Importing Route from a wrong path. The +types/posts.$id segment must match the route file name exactly, including $ params and . separators.

Fix 3: Action Revalidation and shouldRevalidate

By default, all matched route loaders re-run after an action completes. Useful default — but you can opt out per route:

import type { ShouldRevalidateFunction } from "react-router";

export const shouldRevalidate: ShouldRevalidateFunction = ({
  formAction,
  defaultShouldRevalidate,
}) => {
  // Skip revalidation for a noisy /analytics endpoint.
  if (formAction === "/analytics") return false;
  return defaultShouldRevalidate;
};

If you’re seeing stale data after an action, check shouldRevalidate returns aren’t preventing the loader from running.

To force a manual revalidation outside of actions:

import { useRevalidator } from "react-router";

function Refresh() {
  const revalidator = useRevalidator();
  return <button onClick={() => revalidator.revalidate()}>Refresh</button>;
}

Fix 4: Migrate Imports From Remix v2

The biggest mechanical change. Replace these imports across the codebase:

Remix v2React Router v7
@remix-run/reactreact-router
@remix-run/node@react-router/node
@remix-run/cloudflare@react-router/cloudflare
@remix-run/serve@react-router/serve
@remix-run/dev@react-router/dev

The hooks and exports keep the same names: useLoaderData, useActionData, useNavigation, useSubmit, Form, Link, Outlet. Just the import source changes.

The Remix codemod handles this:

npx codemod remix/2/react-router/upgrade

It rewrites imports and updates package.json deps. Review the diff before committing — generated code edits sometimes miss type-only imports.

Fix 5: File-Based vs Config-Based Routing

By default, RR7 uses the app/routes/ convention:

  • app/routes/_index.tsx/
  • app/routes/posts.tsx/posts
  • app/routes/posts.$id.tsx/posts/:id
  • app/routes/_app.tsx → layout for nested routes (leading _ = pathless)
  • app/routes/posts._index.tsx/posts (when posts.tsx is a layout)

For complex apps, switch to explicit config:

// app/routes.ts
import { type RouteConfig, route, layout, index } from "@react-router/dev/routes";

export default [
  index("routes/home.tsx"),
  layout("routes/app-layout.tsx", [
    route("posts", "routes/posts.tsx"),
    route("posts/:id", "routes/post.tsx"),
  ]),
  route("login", "routes/login.tsx"),
] satisfies RouteConfig;

Config-based gives you precise control over nesting, naming, and code organization. File-based is faster for new projects.

Common Mistake: Mixing both. Pick one. If you have an app/routes.ts, the file-based convention is disabled — you must declare every route in config.

Fix 6: SSR vs SPA Mode

For SPA (client-only) deployments:

// react-router.config.ts
import type { Config } from "@react-router/dev/config";

export default {
  ssr: false,
} satisfies Config;

This produces a static index.html and JS bundle that hydrates the routes on the client. Loaders still run, but only on the client.

For partial SSR (some routes server-rendered, others client-only), use clientLoader and clientAction:

// app/routes/dashboard.tsx
export async function clientLoader({ params }) {
  // Runs only on the client. No SSR for this route.
  return await fetch(`/api/dashboard/${params.id}`).then((r) => r.json());
}

export default function Dashboard() {
  const data = useLoaderData<typeof clientLoader>();
  // ...
}

clientLoader runs in addition to (or instead of) loader. Useful for client-only data sources like IndexedDB or browser-only APIs.

Fix 7: Single Fetch and headers

RR7 uses single-fetch: one HTTP request per navigation fetches all loaders’ data together. Cookies and headers from the server response work as expected, but if you have multiple loaders setting their own headers, only the most specific (deepest matching) wins by default.

To merge headers:

export function headers({ loaderHeaders, parentHeaders }) {
  const headers = new Headers(parentHeaders);
  headers.set("Cache-Control", "public, max-age=300");
  return headers;
}

headers runs on the server only and lets you control caching, CORS, and security headers per route.

Pro Tip: For most apps, set Cache-Control on root layouts (app/root.tsx) and let child routes inherit. Add per-route overrides only where the cache behavior genuinely differs.

Fix 8: Hydration Mismatch and Streaming

Streaming SSR (the default in framework mode) splits the response: shell first, then Suspense boundaries hydrate as their data arrives.

Hydration mismatches usually come from:

  • Reading Date.now() or Math.random() during render. Same code, different output on server and client. Move to useEffect or a clientLoader.
  • Reading window / document during render. Same fix — guard with typeof window !== "undefined" or use useEffect.
  • Returning different markup based on env detection. Don’t branch on process.env.NODE_ENV in JSX; use import.meta.env consistently.

For deferred data with explicit Suspense, return unawaited promises from your loader — RR7’s single-fetch streams them in:

import { Await } from "react-router";

export async function loader() {
  return {
    user: await getUser(),  // resolved before render
    posts: getPosts(),       // promise, streams in
  };
}

export default function Page() {
  const { user, posts } = useLoaderData<typeof loader>();
  return (
    <>
      <h1>{user.name}</h1>
      <React.Suspense fallback={<Loading />}>
        <Await resolve={posts}>{(posts) => <List items={posts} />}</Await>
      </React.Suspense>
    </>
  );
}

The shell with user ships immediately; posts streams in once resolved.

Still Not Working?

A few less-obvious failures:

  • Module not found: react-router-dom. RR7 framework mode uses react-router (no -dom). If you upgraded from RR6 in library mode, you may still need react-router-dom for BrowserRouter. In framework mode, drop it entirely.
  • useNavigate returns undefined. You’re calling it outside the <RouterProvider> (library mode) or outside the framework’s automatic context. Framework mode wraps everything for you — make sure the component is rendered inside a route.
  • TypeScript errors after react-router typegen. Delete .react-router/ and re-run. The cache can go stale across major upgrades.
  • Form doesn’t submit to the route’s action. It does, but you may not have an action export in that route. By default Form submits to the closest route with an action.
  • loaderHeaders is empty. Your loader returned a plain object (no Response). To set response headers from a loader, return a Response with headers explicitly: return new Response(JSON.stringify(data), { headers: { ... } }) or use data(...) helper.
  • Hot reload loses state on every change. Vite HMR + RR7 normally preserves state. If yours doesn’t, check that @vitejs/plugin-react isn’t loaded twice (RR7 wraps it).
  • 404 for routes that exist. Filename has a typo, or the route uses unsupported characters. RR7 file routing has specific rules: posts.$id (not posts/[id]).
  • Cloudflare Workers deployment: Cannot find module 'node:async_hooks'. Use @react-router/cloudflare and the Cloudflare adapter, not the Node one.
  • useLoaderData<typeof loader>() shows never. TypeScript can’t infer the loader return because the file is excluded from the program. Check that the test/route file is in tsconfig.json’s include, and that there’s no "isolatedModules"+"verbatimModuleSyntax" combination silently breaking inferred re-exports.
  • Form posts a multipart/form-data body that the action can’t parse. RR7’s default formData() parses both encodings, but if you set encType="multipart/form-data" and stream a large file, the request hits a default 1 MB body limit on Cloudflare Workers and similar runtimes. Configure the platform’s body limit, or stream via a presigned upload URL instead.
  • react-router build produces a server bundle that fails on a Cannot resolve "react" error at boot. Your vite.config.ts has a ssr.noExternal list that doesn’t include react. Either explicitly mark react as external for the server build, or let RR7’s default Vite config handle it (don’t override ssr.noExternal unless you have a specific reason).

For related React Router and SSR issues, see Remix not working, React Router no routes matched, Next.js hydration failed, and Vite failed to resolve import.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles