How Data Loading Works in Remix 3
See how Remix 3 replaces loaders with controller actions for data loading, and how routes, streaming Frames, pending states, cancellation, and errors work.
Remix 3 loads page data inside a controller action that handles a GET route, and that action returns a standard Web Response, usually produced by context.render().
If you have shipped a Remix 2 app, your first instinct is probably to look for the loader export and the hook that reads it. You won’t find them in the new guides, and older posts that say loaders carried over into Remix 3 don’t match what the Remix 3 documentation shows.
Remix 3 (v3.0.0), released 1 October 2026, uses a different model from the Remix 1/2 loader approach described in this overview of Remix’s original loader-based architecture. This article explains where data fetching now lives, how first loads and navigations stream, how pending UI works, and where errors go.
Key Takeaways
- Remix 3 fetches page data inside a
createController()action for a GET route, and the action passes that data as props to the component it renders withcontext.render(). - Routes are declared in
app/routes.ts, implemented by controllers, and registered withrouter.map(). Each controller owns only the direct leaves of its route map. - A
Framewith nofallbackholds back the initial HTML until its route responds. AFramewith afallbackstreams that fallback first and replaces it later. - Expected failures such as missing records should be returned as
Responseobjects with a 404 or 400 status. A thrown error makesrouter.fetch()reject, and the server boundary returns a 500. - When a deferred frame fails after its fallback has been sent, the error is reported and the fallback stays on screen.
What Replaced the Loader in Remix 3?
In Remix 3, a controller action does the job a route loader used to do. The route map declares a URL and HTTP method, and createController() binds one action to each route leaf. The action reads typed values from context.params, awaits your data, and returns a Response. The Routing and Controllers guide shows this pattern, and loader exports do not appear anywhere in it.
// app/actions/albums/controller.tsx
import { createController } from "remix/router";
import { routes } from "../../routes.ts";
// getAlbum and getRecommendations are app data functions, not Remix APIs.
import { getAlbum, getRecommendations } from "./data.ts";
import { AlbumRecommendations } from "./recommendations.tsx";
import { AlbumPage } from "./show-page.tsx";
export default createController(routes.albums, {
actions: {
async show(context) {
// The second argument is an app-defined signal parameter.
let album = await getAlbum(context.params.albumId, context.request.signal);
if (!album) return new Response("Album not found", { status: 404 });
return context.render(<AlbumPage album={album} />);
},
async recommendations(context) {
let album = await getAlbum(context.params.albumId, context.request.signal);
if (!album) return new Response("Album not found", { status: 404 });
let recommendations = await getRecommendations(album);
return context.render(<AlbumRecommendations albums={recommendations} />);
},
},
});
The main change from Remix 2 is the direction of data flow. In Remix 2, the component pulled its data through a hook. In Remix 3, the action pushes data into the component as props. context.render exists only when the render() middleware is installed on the router. Without it, an action can still return a plain Response or Response.json().
Where Does Data Loading Live in Remix 3?
A Remix 3 app declares its URLs and HTTP methods in app/routes.ts, implements one action per route leaf with createController(), and registers each controller with router.map().
// app/routes.ts
import { get, route } from "remix/routes";
export const routes = route({
albums: route("/albums", {
show: get("/:albumId"),
recommendations: get("/:albumId/recommendations"),
}),
});
routes.albums.show.href({ albumId: "thriller" }); // "/albums/thriller"
The href() helper on each route is typed. Because :albumId is a path variable, albumId is a required argument. If you rename the pattern later, any call that still passes the old key fails type checking.
// app/router.ts
import { createRouter } from "remix/router";
import albumsController from "./actions/albums/controller.tsx";
import { render } from "./middleware/render.tsx"; // the app's own render middleware, built with renderWith()
import { routes } from "./routes.ts";
export const router = createRouter({
middleware: [render()],
});
router.map(routes.albums, albumsController);
A controller owns only the direct leaves of the map you pass it, so nested maps need their own router.map() call. Remix checks every mapping at setup: a controller with no action for one of its leaves makes setup throw before any request is served. In practice, plan for one GET route per page, plus one more for each region that loads on its own.
How Do First Load and Navigation Work in Remix 3?
On first load, Remix 3’s render middleware streams one HTML document. On navigation, after run() starts in the browser, same-origin links and forms can refill a single named Frame instead of replacing the whole document. When a region needs its own request, the page renders it with Frame, pointed at that region’s GET route. The action behind that route returns just the region’s markup and leaves out the app’s document shell.
// app/actions/albums/show-page.tsx
import { Frame } from "remix/component";
import type { Handle } from "remix/component";
import { routes } from "../../routes.ts";
import { Document } from "../document.tsx"; // app-defined shell component
import type { Album } from "./data.ts";
export function AlbumPage(handle: Handle<{ album: Album }>) {
return () => {
let { album } = handle.props;
return (
<Document title={album.title}>
<h1>{album.title}</h1>
<Frame
name="album-recommendations"
src={routes.albums.recommendations.href({ albumId: album.id })}
fallback={<p>Loading recommendations…</p>}
/>
</Document>
);
};
}
A Frame with no fallback is blocking: Remix doesn’t send the first chunk of HTML until the frame’s route has responded, so the page arrives whole. Add a fallback and it ships in the initial HTML instead, then gets swapped for the frame route’s output when that action completes. Choose blocking when the page is meaningless without the region, and a fallback when the rest of the page is worth showing first.
After run() starts in the browser, same-origin links and forms can fill a named frame instead of navigating the whole document. These attributes are documented in Streaming UI with Frames:
| Attribute | Effect |
|---|---|
data-rmx-target | Picks a mounted named frame |
data-rmx-src | Sets the request URL that fills the frame |
data-rmx-reset-scroll="false" | Keeps the current scroll position |
data-rmx-history="push|replace" | Overrides the default history behavior |
data-rmx-document | Opts the link or form out of interception |
On a link, the address bar still moves to href, while data-rmx-src decides what loads into the frame.
<form
action={routes.albums.recommendations.href({ albumId: album.id })}
method="get"
data-rmx-target="album-recommendations"
>
<button type="submit">Refresh recommendations</button>
</form>
Components can also reload frames themselves. handle.frame.reload() reloads whichever frame contains the component, and await handle.frames.get("album-recommendations")?.reload() targets a named frame elsewhere on the page. If no frame with that name is mounted, handle.frames.get() gives back undefined. Frame routes should only return trusted HTML, so clean any untrusted content on the server first.
Pending States: Fallbacks and Cancellation
Remix 3 handles pending UI with Frame fallbacks and request cancellation. The Frame fallback is the initial loading UI, and the guides don’t document a global navigation-pending hook.
Cancellation is something the app wires up. The browser frame resolver you pass to run() receives a signal, and the guides say to hand it to fetch() so a frame request stops when its frame is removed or a newer request replaces it. On the server, the render middleware passes request.signal to renderToStream() and to its frame requests. To make cancellation reach your own code, pass request.signal into database and fetch calls too. According to Errors and Cancellation, when a request aborts, the router rejects with request.signal.reason.
Error States: Responses and Reporting Hooks
In Remix 3, return a missing record or invalid input as a Response with a 404 or 400 status. A thrown error makes router.fetch() reject, and the server boundary then answers with a 500. Remix 3 has no catch-all component error boundary; each kind of failure goes to its own destination:
| Failure | Where it goes |
|---|---|
| Expected (missing record, bad input) | Returned Response with a 404 or 400 status |
| Throw in an action or middleware | router.fetch() rejects; the server boundary reports it and returns a 500 |
| Root or blocking-frame render failure | Error is reported and the stream rejects |
| Deferred frame failure after the fallback was sent | Error is reported; the fallback stays in the page |
| Browser runtime (hydration, component, scheduler, frame) | error event on the value returned by run() |
At the server boundary, createRequestListener from remix/node-fetch-server takes an onError option. It’s where uncaught errors get logged, and it can optionally return a Response to send in place of the default 500. The onError option on the render middleware and the browser error event only report errors. For frames, your app’s own resolver decides what a failed response becomes: bounded HTML for that region, or an error sent to those reporting hooks. The resolver in the guides throws on any status that isn’t OK, so every failed frame request gets reported.
When a deferred frame fails after its fallback has already been sent, the server can’t swap the page for an error response, because body bytes are already on the wire. The error gets reported and the fallback stays on screen. A server log records that one error, but it doesn’t show what the user saw. Session replay fills that gap: you can watch users sit on a “Loading…” message that never resolved while they kept using the rest of the page. Write fallbacks so a stalled region still makes sense if it never fills in.
Migrating From Remix 2
Moving a Remix 2 app to Remix 3 is a rewrite, not an upgrade. Routes move into a typed route map, data loading moves into controller actions, and UI moves onto Remix’s own component runtime in remix/component. The guides have no migration chapter. Remix 2’s in-place upgrade path is React Router v7, and Remix 3 is a separate codebase. Treat guides like this Next.js-to-Remix walkthrough, written for Remix 1/2, as background only, not as a guide to Remix 3.
Conclusion
Remix 3 data loading comes down to this: a GET route, a controller action that awaits data and returns a Response, and frames for regions that load or fail on their own. To get started, map one page’s routes in app/routes.ts, move its data fetching into a controller, and decide which regions need a fallback. For more on working with the new framework, see how Remix 3 is designed for AI coding agents.
FAQs
Does Remix 3 use React?
No. Remix 3 keeps JSX syntax but does not run on React or React Router. It uses its own component model, exported from remix/component. A component is an outer setup function that receives a handle and runs once, plus an inner render function that runs on each update. React hooks such as useState are not part of this model, so React component libraries do not work in Remix 3 without changes.
How do form submissions and mutations work in Remix 3?
Declare the form with the form() route helper in app/routes.ts. By default it creates two leaves at the same URL: an index GET route that shows the form and an action POST route that handles the submission. Both leaves belong to the same controller. After a successful create, update, or delete, the Remix 3 guides recommend returning a 303 redirect so that refreshing the page does not resubmit the form.
Can a Remix 3 controller action return JSON instead of HTML?
Yes. A controller action can return any Web Response, including Response.json(), redirects, files, and streams. The Remix 3 Forms and Mutations guide suggests JSON for cases such as autocomplete, polling, self-contained widgets, APIs, and calls between services. It warns against adding a separate JSON mutation path for a form that already works through an HTML response and a redirect.
Is Remix 3 the same as React Router v7?
No. Remix v2 was merged into React Router v7, released in November 2024, as its Framework Mode. That is the upgrade path for Remix 2 apps that want to keep loaders and React. Remix 3 is a separate project, announced in May 2025 and built on its own component model. Moving to it means rewriting routes, data loading, and UI.
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k