11 KiB
Hydration, Frames, and Navigation
What This Covers
How server-rendered UI becomes interactive in the browser, and how the page updates without a full navigation. Read this when the task involves:
- Marking a component for client-side hydration with
clientEntry - Booting the client runtime with
run - Streaming server content into a region of the page with
<Frame>and reloading those regions - Handling browser HMR updates for hydrated entries
- Triggering Navigation API transitions with
navigate(...)orlink(...) - Server rendering with
renderToStreamorrenderToString - Managing the document
<head>
For component-local state and updates, see component-model.md. For host-element behavior and events, see mixins-styling-events.md. For browser asset HMR setup, see assets-and-browser-modules.md.
Server First, Then Hydrate
Make the server route correct before adding clientEntry(...). A POST should already do the right thing on its own — return HTML, a redirect, or an error response — and a GET should already render the page the user expects. clientEntry exists to layer interactivity on top of UI that already works without it.
When server state changes after a mutation, prefer reloading a <Frame> when the UI region already maps cleanly to a server-rendered route. Frames re-fetch the same route, so the rendering logic stays in one place and the client does not need a parallel "state" API.
on('submit', async (event, signal) => {
event.preventDefault()
await fetch(routes.cart.add.href(), {
method: 'POST',
body: new FormData(event.currentTarget),
signal,
})
if (signal.aborted) return
await handle.frames.get('cart-summary')?.reload()
})
Use polling or a small JSON state endpoint when the data changes outside this page, or when a tiny shared widget would be heavier to model as a frame. Pick the lightest sync mechanism that preserves clear ownership of rendering logic.
Client Entries
Use clientEntry to mark a component for client-side hydration. In source-served apps, prefer the source module's import.meta.url as the entry ID and let server rendering map it to the public asset URL:
import { clientEntry, on, type Handle } from 'remix/ui'
export const Counter = clientEntry(
import.meta.url,
function Counter(handle: Handle<{ initialCount: number; label: string }>) {
let count = handle.props.initialCount
return () => (
<div>
<span>
{handle.props.label}: {count}
</span>
<button
mix={on('click', () => {
count++
handle.update()
})}
>
+
</button>
</div>
)
},
)
On the server, provide resolveClientEntry to renderToStream(...) so source file URLs become browser-loadable asset URLs. Keep this resolution in the render helper so component modules do not hard-code deployment-specific asset paths:
let stream = renderToStream(<App />, {
async resolveClientEntry(entryId, component) {
let exportName = entryId.split('#')[1] || component.name
if (!exportName) {
throw new Error(`Unable to resolve client entry export for ${entryId}`)
}
return {
href: await assetServer.getHref(entryId),
exportName,
}
},
})
If the module export name differs from the component function name, include #ExportName in the entry ID or return the exact export name from resolveClientEntry. A render helper that only supports source-owned entries can also fail fast when entryId is not a file:// URL.
On the server, clientEntry components render like any other component. The server wraps their output in comment markers and serializes props into a <script type="application/json"> tag.
Client entry props must be serializable: strings, numbers, booleans, null, undefined, plain objects/arrays of the above, JSX elements, and <Frame> elements. Functions and class instances cannot be passed.
Booting the Client
Use run to start the client runtime. It scans the document for client entry markers, loads modules, and hydrates each one:
import type { ResolveFrameOptions } from 'remix/ui'
import { run } from 'remix/ui'
const app = run({
async loadModule(moduleUrl, exportName) {
let mod = await import(moduleUrl)
return mod[exportName]
},
async resolveFrame(src, options) {
let headers = new Headers({ accept: 'text/html', 'x-remix-frame': 'true' })
if (options?.target) headers.set('x-remix-target', options.target)
let response = await fetch(src, {
body: getRequestBody(options),
headers,
method: options?.method,
signal: options?.signal,
})
return response.body ?? (await response.text())
},
})
function getRequestBody(options?: ResolveFrameOptions): BodyInit | undefined {
let formData = options?.formData
if (!formData) return
if (options.encType !== 'application/x-www-form-urlencoded') return formData
let body = new URLSearchParams()
for (let [name, value] of formData) {
body.append(name, typeof value === 'string' ? value : value.name)
}
return body
}
app.addEventListener('error', (event) => {
console.error('Component error:', event.error)
})
await app.ready()
run options
loadModule(moduleUrl, exportName)(required) — return the component function for each client entry. Typically uses dynamicimport().resolveFrame(src, options)(optional) — called when a<Frame>loads or reloads content and for intercepted link and form navigations.optionsmay containsignalandtarget; non-GET forms also provideformData,method, andencType.
app methods
app.ready()— resolves when all initial client entries are hydratedapp.flush()— synchronously flushes all pending updatesapp.dispose()— tears down all hydrated components
app is an EventTarget that emits error events from any hydrated component.
Browser HMR Updates
When remix/node-hmr reports a server update, reload the top frame to apply the latest server-rendered document while preserving browser state:
if (import.meta.hot) {
import.meta.hot.on('server:update', async () => {
await app.ready()
await app.frames.top.reload()
})
}
Frames
A <Frame> renders server content into the page. Frames stream after the initial HTML, nest inside other frames, contain client entries, and can be reloaded without full page navigation.
import { Frame } from 'remix/ui'
function App() {
return () => (
<div>
<Frame src="/sidebar" fallback={<div>Loading...</div>} />
<Frame name="main" src="/main-content" />
</div>
)
}
Frame props
src(required) — URL to fetch the frame content fromfallback(optional) — content to show while loading; determines streaming behaviorname(optional) — registers the frame for lookup viahandle.frames.get(name)on(optional) — event handlers for events dispatched from the frame element
Blocking vs non-blocking
- Without
fallback(blocking) — the server waits for frame content before sending the initial HTML chunk - With
fallback(non-blocking) — the fallback renders immediately; real content streams in later and replaces it
Reloading frames
Client entries inside a frame can trigger a reload:
// Reload the containing frame
handle.frame.reload()
// Reload an adjacent named frame
await handle.frames.get('cart-summary')?.reload()
// Reload the entire page/frame tree
handle.frames.top.reload()
When a frame reloads, matching DOM nodes are updated in place. Client entries receive updated props while preserving their local component state.
Form navigation
When run({ resolveFrame }) is active, eligible same-origin forms progressively enhance into frame navigations. Native validation and the form's submit event still run first.
- Forms target
handle.frames.topby default. rmx-targetselects a named frame.rmx-srcselects a different frame request URL while preserving the form action as the navigation destination.rmx-history="push|replace"overrides how the navigation updates history.rmx-reset-scroll="false"preserves scroll position.rmx-documentopts back into a document submission.- Cross-origin forms,
method="dialog", andtarget="_blank"remain browser-owned.
GET controls are already encoded in src, so GET forms reach the resolver like links. Non-GET forms provide their native FormData, effective method, and encoding. The resolver owns body encoding and method-override conventions. Non-GET submissions to the current URL replace its history entry; GET submissions and submissions to a different URL push one. The rmx-history attribute overrides those defaults.
Nested frames
Frames can nest. Each frame owns its own DOM region and hydrates client entries independently. During SSR, handle.frame.src points at the frame being rendered, while handle.frames.top.src stays fixed at the outer document URL.
Server Rendering
renderToStream
Renders a component tree to a ReadableStream<Uint8Array>. Sends initial HTML immediately and streams frame content as it resolves:
import { renderToStream } from 'remix/ui/server'
let stream = renderToStream(<App />, {
frameSrc: request.url,
resolveFrame(src, target, context) {
let frameUrl = new URL(src, context?.currentFrameSrc ?? request.url)
return fetchHtml(frameUrl)
},
onError(error) {
console.error(error)
},
})
return new Response(stream, {
headers: { 'Content-Type': 'text/html; charset=utf-8' },
})
Options:
frameSrc— seeds SSR frame state; populateshandle.frame.srcandhandle.frames.top.srctopFrameSrc— overrides the root frame URL for nested frame renders (carry forward fromresolveFramecontext)resolveFrame(src, target, context)— return HTML string,ReadableStream<Uint8Array>, or a promise of either.context.currentFrameSrcis the containing frame URL;context.topFrameSrcis the outer document URLonError(error)— called on rendering errors
renderToString
Renders a component tree to a complete HTML string. Use for static pages or embedding HTML:
import { renderToString } from 'remix/ui/server'
let html = await renderToString(<App />)
CSS in SSR
Components using the css mixin have styles collected during rendering and emitted as a single <style> tag in <head>. No client-side style injection needed.
Navigation
Use real anchors for normal document navigation. For app-driven navigation:
navigate(href, options?)— performs a Navigation API transitionlink(href, options?)mixin — makes any element behave like a navigation link
import { navigate } from 'remix/ui'
navigate('/dashboard', { history: 'replace' })
Options: src, target, history ('push' | 'replace'), resetScroll.
Attributes understood by the runtime: rmx-target, rmx-src, rmx-history, rmx-reset-scroll, rmx-document.
Head Management
Manage document head with an explicit <head> in your document structure:
function App() {
return () => (
<html>
<head>
<title>Dashboard</title>
<meta name="description" content="Team dashboard" />
<link rel="stylesheet" href="/styles/app.css" />
</head>
<body>
<main>...</main>
</body>
</html>
)
}
Put title, meta, link, and style tags inside an explicit <head>. Bare head-like tags rendered outside <head> stay where they are — they are not moved into the document head for you.