Chapter 6
Streaming UI with Frames
A <Frame> renders the HTML from another route inside the current page. Remix can resolve that
route before it sends the page, stream it in behind a fallback, or reload it later in the browser
without navigating the whole document.
A frame works like an <iframe>. It has a src URL, and the route at that URL returns HTML. Unlike an iframe, the HTML becomes part of the current document. Remix reconciles it into the live DOM, comparing the new HTML with the existing nodes and updating only what changed instead of replacing the region wholesale. That way focus, input values, and the state of hydrated components inside the frame survive a reload. Because the response is plain HTML, you can read it in the browser's network panel, and a frame can load any route that returns HTML, including one that serves a static file.
This guide uses an album page with a recommendations region to cover the frame route, the render middleware that resolves frames on the server, the browser resolver, and the links, forms, and handlers that reload a frame. It ends with choosing between per-region frames and whole-page navigation, and with running the same router in the browser.
Use a frame for route-owned UI
Components render synchronously, and Remix has no async components. Data loading happens in route actions, which pass the results to components as props. A frame is how one region of a page gets its own action, and with it, its own data loading. A component does not need a frame merely because it is slow or interactive. Start with normal component composition, a single rendered page, and forms that navigate. Reach for a frame when a region should have its own route and at least one of these behaviors:
- It can arrive after the surrounding page.
- It needs to reload independently after a mutation.
- Several pages render the same route-owned region.
- Its data and error response belong to a separate request.
Give the region its own GET route. For album recommendations, that route can sit beside the album
page:
import { form, get, route } from "remix/routes";
export const routes = route({
albums: route("/albums", {
show: get("/:albumId"),
recommendations: get("/:albumId/recommendations"),
edit: form("/:albumId/edit"),
}),
});The action renders only the region the frame needs. It does not include the app's Document shell:
import { createController } from "remix/router";
import { routes } from "../../routes.ts";
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) {
let album = await getAlbum(context.params.albumId);
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);
if (!album) return new Response("Album not found", { status: 404 });
let recommendations = await getRecommendations(album);
return context.render(<AlbumRecommendations albums={recommendations} />);
},
},
});The page renders that route by passing its URL to Frame as src:
import { Frame } from "remix/component";
import type { Handle } from "remix/component";
import { routes } from "../../routes.ts";
import { Document } from "../document.tsx";
import type { Album } from "./data.ts";
export function AlbumPage(handle: Handle<{ album: Album }>) {
return () => {
let { album } = handle.props;
return (
<Document title={`${album.title} — Albums`}>
<main>
<h1>{album.title}</h1>
<Frame src={routes.albums.recommendations.href({ albumId: album.id })} />
</main>
</Document>
);
};
}Without a fallback, the frame is blocking. Remix waits for the recommendations response before it
sends the initial HTML chunk. The browser still receives one complete page.
Stream a fallback first
Add fallback when the album page is useful before its recommendations arrive:
<Frame
src={routes.albums.recommendations.href({ albumId: album.id })}
fallback={<p>Loading recommendations…</p>}
/>The fallback is part of the initial HTML. When the recommendations action finishes, Remix streams its rendered HTML and replaces the fallback.
Use a fallback for a region that may arrive later without making the surrounding page confusing.
Keep a frame blocking when the initial page does not make sense without it. A fallback is loading UI,
not a generic error boundary. The frame resolver, the function that fetches a frame's src and
returns its HTML, decides what happens when the request fails, along with your app's error handling.
Render the response as a stream
Frames resolve on the server through the render() middleware from
remix/middleware/render. Add it to your router. It
gives actions context.render(...), streams their component trees, and sends frame requests back
through the same router:
import { render } from "remix/middleware/render";
import { createRouter } from "remix/router";
import { assets } from "./assets.ts";
export const router = createRouter({
middleware: [
render({
assets,
onError(error) {
console.error(error);
},
}),
],
});The middleware forwards same-origin request headers needed by frame routes, strips body and
transport headers from its internal GET requests, follows redirects, and cancels pending frame
work when the outer request is canceled. It also uses the asset server to resolve hydrated
client entries and their module preloads.
Use renderWith(...) from remix/middleware/render
when an app needs a different renderer or response type. Normal HTML actions should use the standard
render() middleware.
renderToString() from remix/component/server is the
smaller alternative when code needs the complete HTML value before it can continue, such as an email
preview or a small embedded fragment:
import { renderToString } from "remix/component/server";
// After loading recommendations:
let html = await renderToString(<AlbumRecommendations albums={recommendations} />);Use context.render(...) for normal page responses. It streams, supports client entries, and lets
frames with fallbacks arrive after the initial HTML.
Resolve frames in the browser
A blocking frame resolves before the browser receives the page. A frame with a fallback may still
be pending, and any frame can reload later. Calling run()
in your browser entry installs a default browser resolver, so these requests need no additional
configuration.
The default resolver fetches same-origin HTML, follows redirects, preserves form submission methods and encoding, and cancels requests when a frame is removed or its request is superseded. It also keeps the redirect destination available so navigation can update the browser URL after a form saves.
Pass your own resolveFrame function in the run() options when your app needs different request
or response handling, such as another body encoding or a header that keeps frame requests on the same
deployment during a rolling deploy. The default resolver is a short fetch() call, so a custom one
can start from a copy of it. The
remix/component API overview documents the callback's
arguments and return values.
Remix does not sanitize returned HTML before reconciling it into the current document, and same-origin user-generated HTML is not implicitly safe. Sanitize untrusted content before returning it.
Name and reload frames
Give a frame a name when another client entry needs to find it:
<Frame
name="album-recommendations"
src={routes.albums.recommendations.href({ albumId: album.id })}
fallback={<p>Loading recommendations…</p>}
/>A component can reload the frame that contains it or a named frame elsewhere on the page:
// Reload the containing frame.
await handle.frame.reload();
// Reload a mounted frame by name.
await handle.frames.get("album-recommendations")?.reload();handle.frames.get(name) returns undefined when that frame is not mounted. After a reload, Remix
updates the region with the route's current HTML. Client entries inside matching content keep their
setup state and receive current server props.
Navigate a frame with a form
Once run() starts, an eligible same-origin form follows the same frame navigation path as a link.
This GET form reloads the recommendations frame without a client entry or submit handler:
<form
action={routes.albums.recommendations.href({ albumId: album.id })}
method="get"
data-rmx-target="album-recommendations"
>
<button type="submit">Refresh recommendations</button>
</form>The form remains a normal document navigation before the runtime starts. Native
constraint validation,
such as required and pattern checks, and the form's submit event run before Remix intercepts it. A submit button's values
take precedence over its form's values. These attributes control the navigation:
data-rmx-targetchooses a named frame.data-rmx-srcprovides a different request URL for that named frame.data-rmx-reset-scroll="manual"preserves the current scroll position.data-rmx-reset-focus="manual"disables the browser's automatic focus reset.data-rmx-history="push|replace"controls how the navigation updates history.data-rmx-documentopts out of interception.
Scroll and focus controls accept "after-transition" (the default) and "manual". Existing "true"
attributes keep the default behavior, and "false" attributes select "manual". The resetScroll and
resetFocus options in navigate() and link() accept the same strings, along with true and false
for compatibility.
GET controls are already encoded in the destination URL. For non-GET forms, the default resolver
sends the form's body with its method and encoding. The action should return HTML for the targeted
frame when it receives a frame request, while keeping its normal document response or redirect for
unenhanced submissions. Non-GET submissions to the current URL replace that history entry, while GET
submissions and submissions to a different URL push one. The data-rmx-history attribute overrides
those defaults.
Coordinate a mutation and a separate frame reload
Start with a form whose action works without browser JavaScript. A client entry can intercept that
same submission, send its FormData, and reload a different route after the action succeeds:
import { on } from "remix/component";
// Inside a client-entry component's render function:
<form
action={routes.albums.edit.action.href({ albumId: album.id })}
method="post"
mix={on("submit", async (event, signal) => {
let form = event.currentTarget;
event.preventDefault();
let response = await fetch(form.action, {
body: new FormData(form),
method: form.method,
signal,
});
if (signal.aborted) return;
if (!response.ok) {
// Render the app's validation or request error here.
return;
}
await handle.frames.get("album-recommendations")?.reload();
})}
>
{/* album fields */}
</form>;The custom handler earns its place because the mutation response and the recommendations frame are
two different requests. The mutation stays in the edit action, the recommendations HTML stays in its
GET action, and the browser component only sequences them. When the form action itself returns the
HTML that belongs in the target frame, prefer data-rmx-target and let the runtime submit it.
Pick the update that matches the response:
- Use a normal navigation when the response should replace the whole page.
- Fetch JSON when a browser component will render the returned data itself.
- Reload a frame when a
GETaction renders the HTML for one region and only that region needs to update.
Target a frame from a link
A link can keep its public destination in href while loading a smaller route into a named frame:
// Inside a component that renders an album link:
<a
href={routes.albums.show.href({ albumId: album.id })}
data-rmx-src={routes.albums.recommendations.href({ albumId: album.id })}
data-rmx-target="album-recommendations"
data-rmx-reset-scroll="manual"
data-rmx-reset-focus="manual"
>
Show recommendations
</a>data-rmx-target chooses a mounted named frame, while data-rmx-src chooses the request used to fill it.
The address bar still moves to href. If the target is omitted, Remix uses href as the source of
the top frame, the implicit frame that holds the whole document, to keep it in sync with the browser
URL. If a specified target does not match a mounted frame,
the browser performs a document navigation. A supplied data-rmx-src must still be a valid same-origin
URL regardless of the target. Invalid or cross-origin values also disable interception.
Add data-rmx-prefetch="intent" or data-rmx-prefetch="viewport" to
prefetch that same source and target before
navigation. When a link attribute doesn't fit, call prefetch(src, { target }) instead.
Add data-rmx-history="replace" when it should replace the current history entry. Use
data-rmx-document when a same-origin link must perform an ordinary document navigation instead.
These "manual" values disable automatic scroll restoration and focus reset during navigation.
They cannot retain focus if the update removes the focused element.
Choose per-region frames or whole-page navigation
Once run() starts, every intercepted link and form already reloads the top frame, which is the whole document. Remix reconciles the new page into the current one, so client entries keep their local state and the browser keeps its scroll and focus behavior. Named frames are an addition on top of that, not a requirement. Choose between two shapes:
- Whole-page navigation. Render regions as ordinary components inside the page, and let links and forms reload the top frame. Each action returns a complete document, so a route's code stays in one place and doesn't need to distinguish frame requests from document requests. The cost is that every interaction renders the whole page on the server, which works best when that render is fast.
- Per-region frames. Give a region its own route and target it with
data-rmx-targetorhandle.frames.get(name)?.reload(). Only that region renders and reloads. The cost is indirection. To see what a region renders, you follow itssrcto another route, and a route that serves both a full page and a named frame has to check the request. The default resolver sendsX-Remix-Frame: trueon every frame request and addsX-Remix-Targetfor named frames.
Start with whole-page navigation and move a region into its own frame when it needs to stream after the page, reload on its own, or be shared by several pages.
Resolve frames without a server
The router doesn't have to run on a server. Routes, controllers, and middleware are built on Request and Response, so the same router can run in the browser for a client-rendered app. remix/spa connects it to the browser runtime:
import { createRouter } from "remix/router";
import { render, run } from "remix/spa";
import { routes } from "./routes.ts";
import controller from "./actions/controller.tsx";
const router = createRouter({
middleware: [render()],
});
router.map(routes, controller);
const app = run(router, { fallback: <p>Loading…</p> });
await app.ready();Two pieces change compared to a server-rendered app:
render()fromremix/spareplaces the render middleware. Actions still callcontext.render(...)and return aResponse, so redirects, status codes, and middleware work as before, but the rendered component tree goes straight to the browser runtime instead of being serialized to HTML. Passrender()a function to wrap every route in a shared layout.run(router)fromremix/sparesolves the current URL and every later frame navigation by callingrouter.fetch(...)in the page. No request reaches the network unless an action makes one.
Controllers and routes can be shared unchanged between the two setups. What changes is the infrastructure around them. A browser can't open a database, so actions read from a browser-side store or call an API instead.
Handle failures and cancellation
The server renderer's onError and the browser app's error event are reporting hooks. The default
browser resolver renders HTML validation responses such as 400, but reports server errors (500
and above) and non-HTML error responses through the browser app's error event.
If a deferred frame fails after its fallback has been sent, the server cannot replace the whole page with a new error response. Keep the fallback useful, report the error, and let the surrounding page continue when that is safe.
Pass request.signal through server frame work. The default browser resolver forwards its cancellation
signal to fetch(), and a custom resolver should do the same.
Decide how to report streaming render failures and frame-resolution failures. In production, make sure aborts propagate through streaming work and that compression doesn't break streams.