Remix Route Loaders and Actions: Server-First Form Data Flow
Learn how Remix route-level loaders and actions create a server-first data contract, how progressive enhancement keeps forms working before hydration, and which version-specific pitfalls to avoid.
13 Feb 2026, 19:37 UTC

Single-page applications often move data fetching into client components, which means forms break before JavaScript hydrates and the browser stops being a reliable source of truth. In Remix—and in React Router v7 when used in framework mode—each route module can export a loader for reads and an action for writes. The useful takeaway is that if those functions return a Response and you render with <Form> or useFetcher, the server remains the source of truth and the same form works before any client JavaScript runs.
How the route data contract works
A route module is the unit of data and UI. The loader runs when the route matches a GET request. The action runs when a <Form> or fetcher submits a non-GET request to that route. Both are plain async functions that receive the Web Request and must return a Web Response.
Components read the results with useLoaderData and useActionData. These hooks return the parsed body of the JSON response produced by the json() helper. Because the types are inferred from the server functions, the client contract stays tied to the server implementation.
After an action completes, Remix automatically revalidates the loaders for every currently matched route. That means a mutation in a child route can refresh data in a parent route without extra code.
A worked route module
The following example shows a list route that reads items and accepts a new item. It is written for Remix v2 syntax; React Router v7 framework mode uses the same exports with imports from react-router.
import {
json,
redirect,
Form,
useLoaderData,
useActionData,
useNavigation,
useRouteError,
} from "@remix-run/react"; // use "react-router" in React Router v7
export async function loader() {
const items = await db.items.findMany();
return json({ items });
}
export async function action({ request }) {
const form = await request.formData();
const name = form.get("name");
if (!name || typeof name !== "string") {
return json({ error: "Name is required" }, { status: 400 });
}
await db.items.create({ name });
return redirect("/items");
}
export default function ItemsRoute() {
const { items } = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const { state } = useNavigation();
return (
<div>
<ul>
{items.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
<Form method="post">
<input name="name" />
<button type="submit" disabled={state !== "idle"}>
{state === "submitting" ? "Saving..." : "Add"}
</button>
{actionData?.error && <p>{actionData.error}</p>}
</Form>
</div>
);
}
export function ErrorBoundary() {
const error = useRouteError();
return (
<div>
<h1>Something went wrong</h1>
<p>{error.status === 404 ? "Not found" : "Unexpected error"}</p>
</div>
);
}
In this shape, a successful mutation returns redirect("/items"), which sends the browser back to the same route and triggers a fresh loader call. A validation failure returns json({ error }, { status: 400 }), which useActionData surfaces without losing the form state.
Progressive enhancement without extra code
The <Form> component renders a real HTML <form> element. Before JavaScript loads, the browser submits it as a normal document request. The server runs the action, returns a Response, and the browser renders the resulting HTML. After hydration, Remix intercepts the submit, runs the same action through fetch, and updates the UI without a full page reload.
For mutations that should not change the URL—such as a "like" button or an inline edit—use useFetcher. It submits to an action without navigating, so multiple fetchers can be in flight at once. Because fetcher submissions still revalidate matched loaders, the UI stays in sync with the server.
Error boundaries for expected failures
When a loader or action throws a Response—for example throw new Response("Not found", { status: 404 })—the nearest exported ErrorBoundary catches it. In Remix v2 and React Router v7, that boundary uses useRouteError() to read the thrown value. In Remix v1, the API was split into CatchBoundary for expected Response errors and ErrorBoundary for unexpected exceptions, so check your installed version before relying on one shape.
Limits and common mistakes
- An action must return a
Response. Returning a bare object orundefinedbreaks the contract. Always returnjson(...),redirect(...), or throw aResponse. - Response helpers are request-scoped. Calling
json()orredirect()outside aloaderoractionfails because they depend on the active request and response headers. - Loaders run in parallel. A child route loader cannot assume its parent loader has finished. If the child needs parent data, either duplicate the query or restructure the routes.
- Serialization is lossy.
useLoaderDatareturnsSerializeFrom<typeof loader>.Date,Map, and class instances become strings or plain objects. Hand-written interfaces drift; prefer inferred types. - Revalidation can be expensive. A form post revalidates every active route loader by default. On costly routes, use
shouldRevalidateor server-side caching to avoid redundant database work. - Version drift is real. Remix v2 is the final standalone release. The same APIs now ship inside React Router v7 under
react-routerwith framework mode. Legacy@remix-run/*packages are maintenance-only, and behaviors such asdeferandCatchBoundarydiffer across versions.
How to verify it on your version
Build a route with both a loader and an action, submit the <Form>, and open your browser's network panel. You should see a POST to the route followed by one or more GET requests as loaders revalidate. The page should not perform a full document reload.
Disable JavaScript in your browser and submit the same form. If the form still posts and renders server HTML, progressive enhancement is working.
To confirm which package line you are on, run the following in your project root:
npm ls @remix-run/react react-router
Expected check: the output shows either @remix-run/react at a v2.x version or react-router at v7.x. If you see both, verify which one your route modules actually import from, because the import path determines the available APIs.
Finally, throw new Response("Not found", { status: 404 }) from a loader and confirm the nearest ErrorBoundary renders while the application shell stays mounted. If the boundary does not appear, check that you are using the ErrorBoundary export name and useRouteError hook that match your installed version.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.