How toHydrate Isolated UI with Remix Client Entries

Island architecture is useful when most of a page can stay as HTML, but a small part needs browser APIs. A passkey login page is a good example: the page shell can be rendered on the server, while the WebAuthn prompt needs navigator.credentials in the browser.

Let's build that with Remix client entries. The end result is a login page where only the passkey prompt hydrates, and the rest of the page stays as HTML rendered on the server.

Define the Routes

Start with the URL contract. We need one page route for the login screen, one POST route for the WebAuthn verification result, and one asset route for the browser modules Remix will serve.

app/routes.ts
import { get, post, route } from "remix/routes"; export let routes = route({ app: route({ login: get("/login"), verifyPasskey: post("/api/webauthn/verify"), dashboard: get("/dashboard"), }), assets: get("/assets/*path"), });

I like starting with routes.ts because everything else can reference the route object instead of writing URLs by hand. That matters more once the client entry starts posting data back to the server.

Create the Passkey Island

The island is a normal Remix UI component wrapped in clientEntry. Use import.meta.url as the entry ID so the server render helper can turn the source module into the public asset URL.

app/assets/passkey-sign-in.tsx
import type { Handle } from "remix/ui"; import { clientEntry, on } from "remix/ui"; interface PublicKeyCredentialRequestOptionsJSON { challenge: string; rpId: string; allowCredentials: Array<PublicKeyCredentialDescriptorJSON>; timeout?: number; userVerification?: UserVerificationRequirement; } interface PublicKeyCredentialDescriptorJSON { id: string; type: "public-key"; transports?: Array<AuthenticatorTransport>; } interface PasskeySignInProps { challengeId: string; email: string; options: PublicKeyCredentialRequestOptionsJSON; verifyUrl: string; } interface VerifyPasskeyResponse { error?: string; redirect?: string; } export let PasskeySignIn = clientEntry( import.meta.url, function PasskeySignIn(handle: Handle<PasskeySignInProps>) { let status: "idle" | "authenticating" | "error" | "success" = "idle"; let errorMessage: string | null = null; let canRetry = false; async function authenticate(signal?: AbortSignal) { status = "authenticating"; errorMessage = null; canRetry = false; handle.update(); let credential = await navigator.credentials.get({ publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(handle.props.options), signal, }); if (!credential) { status = "error"; errorMessage = "Authentication was canceled."; canRetry = true; return handle.update(); } let response = await fetch(handle.props.verifyUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ challengeId: handle.props.challengeId, response: (credential as PublicKeyCredential).toJSON(), }), signal, }); let data = (await response.json()) as VerifyPasskeyResponse; if (!response.ok) { status = "error"; errorMessage = data.error ?? "Authentication failed."; canRetry = true; return handle.update(); } status = "success"; handle.update(); if (data.redirect) window.location.href = data.redirect; } handle.queueTask((signal) => authenticate(signal)); return () => ( <div> <p> Signing in as <strong>{handle.props.email}</strong> </p> {status === "idle" && <p>Preparing passkey prompt...</p>} {status === "authenticating" && <p>Use your passkey to continue.</p>} {status === "error" && ( <div> <p>{errorMessage}</p> {canRetry && ( <button type="button" mix={on("click", () => authenticate())}> Try Again </button> )} </div> )} {status === "success" && <p>Success. Redirecting...</p>} </div> ); }, );

Remix UI components keep state in the setup closure, and handle.update() tells Remix when to render again.

The call to queueTask runs after the markup from the server has hydrated. That gives the browser runtime a chance to connect the island before opening the passkey prompt.

Render the Login Page

Now render the page from a controller. The page creates a challenge on the server, passes the serializable values into the island, and keeps the rest of the markup owned by the server.

app/actions/controller.tsx
import { createController } from "remix/router"; import { PasskeySignIn } from "../assets/passkey-sign-in.tsx"; import { assetServer } from "./assets/controller.ts"; import { routes } from "../routes.ts"; export default createController(routes.app, { actions: { async login(ctx) { let challenge = await createWebAuthnChallenge(); let entryHref = await assetServer.getHref("app/assets/entry.ts"); return ctx.render( <html lang="en"> <head> <title>Sign In</title> <meta name="viewport" content="width=device-width, initial-scale=1" /> <script type="module" src={entryHref}></script> </head> <body> <main> <h1>Sign In</h1> <p>Use the passkey saved for {challenge.email}.</p> <PasskeySignIn challengeId={challenge.id} email={challenge.email} options={{ challenge: challenge.challenge, rpId: challenge.rpId, allowCredentials: challenge.credentials, timeout: 60_000, userVerification: "preferred", }} verifyUrl={routes.app.verifyPasskey.href()} /> <noscript> <p>JavaScript is required for passkey sign in.</p> </noscript> </main> </body> </html>, ); }, }, });

The PasskeySignIn props are the boundary between server and browser. Keep that boundary boring: strings, numbers, booleans, arrays, and plain objects.

Add the Render Middleware

The page can render the island on the server, but Remix still needs to know which browser module should hydrate it. Use renderWith to add ctx.render to every route context, then resolve client entries from the asset server there.

app/router.ts
import { createHtmlResponse } from "remix/response/html"; import { renderWith } from "remix/middleware/render"; import type { RemixNode } from "remix/ui"; import { renderToStream } from "remix/ui/server"; import { assetServer } from "./actions/assets/controller.ts"; let render = renderWith(() => { return async function render(node: RemixNode, init?: ResponseInit) { let stream = renderToStream(node, { async resolveClientEntry(entryId, component) { let exportName = entryId.split("#")[1] || component.name; return { exportName, href: await assetServer.getHref(entryId), }; }, }); return createHtmlResponse(stream, init); }; }); let middleware = [render];

With this middleware, controllers do not import a render helper. Source modules own their own clientEntry(import.meta.url, ...) declaration, the asset server owns the public URL, and route handlers call ctx.render(...).

Serve Browser Modules

Instead of adding a Vite input, expose a Remix asset route. This route compiles allowed source files for the browser and rewrites imports to the same asset URL space.

app/actions/assets/controller.ts
import { createAssetServer } from "remix/assets"; import { createController } from "remix/router"; import { routes } from "../../routes.ts"; export let assetServer = createAssetServer({ basePath: "/assets", rootDir: process.cwd(), fileMap: { "/app/*path": "app/*path", "/node_modules/*path": "node_modules/*path", }, allow: ["app/assets/**", "node_modules/**"], deny: ["app/**/*.server.*"], target: { es: "2020", chrome: "109", safari: "16.4" }, }); export default createController(routes.assets, { actions: { async assets({ request }) { return (await assetServer.fetch(request)) ?? new Response("Not Found", { status: 404 }); }, }, });

The allow and deny lists are important. Anything served by this route becomes reachable by the browser, so keep the asset surface narrow.

Boot the Client Runtime

The server can mark islands, but the browser still needs one small entry that starts the Remix UI runtime. This file loads the module URL emitted for each client entry and returns the requested export.

app/assets/entry.ts
import { run } from "remix/ui"; let app = run({ async loadModule(moduleUrl, exportName) { let mod = await import(moduleUrl); return mod[exportName]; }, }); await app.ready();

The login controller includes this entry with assetServer.getHref("app/assets/entry.ts"). That gives you one boot script for the page, while each island still gets its own module when Remix hydrates it.

Verify the Passkey Result

The island posts the credential result to a route that returns JSON. The server should still own the authentication decision and redirect target.

app/actions/controller.tsx
import { createController } from "remix/router"; import { PasskeySignIn } from "../assets/passkey-sign-in.tsx"; import { assetServer } from "./assets/controller.ts"; import { routes } from "../routes.ts"; interface VerifyPasskeyBody { challengeId: string; response: ReturnType<PublicKeyCredential["toJSON"]>; } export default createController(routes.app, { actions: { async login(ctx) { let challenge = await createWebAuthnChallenge(); let entryHref = await assetServer.getHref("app/assets/entry.ts"); return ctx.render( <html lang="en"> <head> <title>Sign In</title> <meta name="viewport" content="width=device-width, initial-scale=1" /> <script type="module" src={entryHref}></script> </head> <body> <main> <h1>Sign In</h1> <p>Use the passkey saved for {challenge.email}.</p> <PasskeySignIn challengeId={challenge.id} email={challenge.email} options={{ challenge: challenge.challenge, rpId: challenge.rpId, allowCredentials: challenge.credentials, timeout: 60_000, userVerification: "preferred", }} verifyUrl={routes.app.verifyPasskey.href()} /> <noscript> <p>JavaScript is required for passkey sign in.</p> </noscript> </main> </body> </html>, ); }, async verifyPasskey(ctx) { let body = (await ctx.request.json()) as VerifyPasskeyBody; let result = await verifyWebAuthnResponse(body); if (!result.ok) { return Response.json({ error: "We could not verify that passkey." }, { status: 401 }); } return Response.json({ redirect: routes.app.dashboard.href() }); }, }, });

This route is intentionally small. In a real app, this is where you would validate the JSON body, check the challenge, create the session, and return the next URL.

Register the Controllers

Wire each route map to the controller that owns it. The asset route is just another controller, which keeps the browser module pipeline inside Remix instead of in config for a specific bundler.

app/router.ts
import { renderWith } from "remix/middleware/render"; import { createHtmlResponse } from "remix/response/html"; import { createRouter } from "remix/router"; import type { RemixNode } from "remix/ui"; import { renderToStream } from "remix/ui/server"; import rootController from "./actions/controller.tsx"; import assetsController, { assetServer } from "./actions/assets/controller.ts"; import { routes } from "./routes.ts"; let render = renderWith(() => { return async function render(node: RemixNode, init?: ResponseInit) { let stream = renderToStream(node, { async resolveClientEntry(entryId, component) { let exportName = entryId.split("#")[1] || component.name; return { exportName, href: await assetServer.getHref(entryId), }; }, }); return createHtmlResponse(stream, init); }; }); let middleware = [render]; export let router = createRouter({ middleware }); router.map(routes.app, rootController); router.map(routes.assets, assetsController);

At this point the server owns the page, the asset route owns browser modules, and the client entry owns only the passkey interaction.

Final Thoughts

Client entries are a good fit when a small part of a page needs APIs that only exist in the browser. They let you keep the route, data, and HTML rendering on the server, while hydrating only the piece that actually needs to run in the browser.

You can use the same pattern for search boxes, upload widgets, rich inputs, and other isolated UI. The trade off is that you now have an explicit boundary between the server and the client, so keep the props serializable and the browser module focused.