Skip to content

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

FixDevs ·

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.

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.

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