Fix: React Router 7 Not Working — Framework Mode, Loaders, Type Safety, and Remix Migration
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.
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.
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: 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.
Fix: next-themes Not Working — Hydration Mismatch, Tailwind Dark Mode, FOUC, and System Preference
How to fix next-themes errors — hydration mismatch on mount, FOUC flash before theme applies, Tailwind dark: classes not switching, ThemeProvider in App Router, defaultTheme system not respected, and TypeScript types.