Fix: React Router 7 Not Working — Framework Mode, Loaders, Type Safety, and Remix Migration
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-domstyle. 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 becomereact-routeror@react-router/....react-router-domis still there for library mode users but doesn’t enable framework features.vite.config.tsneeds@react-router/dev/vitefor 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 inRouterProviderwith acreateBrowserRoutertree. - 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 devandreact-router build. Earlier 7.0 betas required a separatereact-router typegenstep. - v7.2+ (mid 2025): React Server Components support began landing behind the
unstable_rscflag inreact-router.config.ts. Hydration semantics shift again —clientLoaderruns at different times depending on whether the route is RSC or client. - v7.5+ (late 2025): Codemod tooling from
@react-router/codemodmatured 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 includingparams(matching the route’s$idsegment).Route.ActionArgs— typed args for actions.Route.ComponentProps— props includingloaderData,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 v2 | React Router v7 |
|---|---|
@remix-run/react | react-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/upgradeIt 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→/postsapp/routes/posts.$id.tsx→/posts/:idapp/routes/_app.tsx→ layout for nested routes (leading_= pathless)app/routes/posts._index.tsx→/posts(whenposts.tsxis 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()orMath.random()during render. Same code, different output on server and client. Move touseEffector aclientLoader. - Reading
window/documentduring render. Same fix — guard withtypeof window !== "undefined"or useuseEffect. - Returning different markup based on env detection. Don’t branch on
process.env.NODE_ENVin JSX; useimport.meta.envconsistently.
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 usesreact-router(no-dom). If you upgraded from RR6 in library mode, you may still needreact-router-domforBrowserRouter. In framework mode, drop it entirely.useNavigatereturnsundefined. 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. Formdoesn’t submit to the route’s action. It does, but you may not have anactionexport in that route. By defaultFormsubmits to the closest route with anaction.loaderHeadersis empty. Your loader returned a plain object (noResponse). To set response headers from a loader, return aResponsewith headers explicitly:return new Response(JSON.stringify(data), { headers: { ... } })or usedata(...)helper.- Hot reload loses state on every change. Vite HMR + RR7 normally preserves state. If yours doesn’t, check that
@vitejs/plugin-reactisn’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(notposts/[id]). - Cloudflare Workers deployment:
Cannot find module 'node:async_hooks'. Use@react-router/cloudflareand the Cloudflare adapter, not the Node one. useLoaderData<typeof loader>()showsnever. TypeScript can’t infer the loader return because the file is excluded from the program. Check that the test/route file is intsconfig.json’sinclude, and that there’s no"isolatedModules"+"verbatimModuleSyntax"combination silently breaking inferred re-exports.Formposts amultipart/form-databody that the action can’t parse. RR7’s defaultformData()parses both encodings, but if you setencType="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 buildproduces a server bundle that fails on aCannot resolve "react"error at boot. Yourvite.config.tshas assr.noExternallist that doesn’t includereact. Either explicitly markreactas external for the server build, or let RR7’s default Vite config handle it (don’t overridessr.noExternalunless 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.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Module not found: Can't resolve / Cannot find module or its corresponding type declarations
How to fix 'Module not found: Can't resolve' in webpack, Vite, and React, and 'Cannot find module or its corresponding type declarations' in TypeScript. Covers missing packages, wrong import paths, case sensitivity, path aliases, node_modules corruption, monorepo hoisting, barrel files, and asset imports.
Fix: [vite] Internal server error: Failed to resolve import
How to fix Vite's 'Failed to resolve import' error, including 'Does the file exist?', 'Optimized dependency needs to be force included', 'Pre-transform error', and '504 (Outdated Optimize Dep)'. Covers missing packages, path aliases, optimizeDeps, cache clearing, and CJS/monorepo edge cases.
Fix: Next.js 15 cookies() Should Be Awaited — Route Used cookies, Cannot Modify Errors, and Library Mismatch
Fix Next.js 15 async cookies() and headers() errors — 'Route used cookies', 'Cookies can only be modified in a Server Action or Route Handler', codemod misses, library compatibility, and TypeScript type mismatches after upgrade.
Fix: Astro Content Collections Not Working — Content Layer, Loaders, Schema, and References
How to fix Astro content collections errors — content/config.ts moved to content.config.ts, glob loader patterns, schema validation, references between collections, live reload on add/remove, and remote loaders.