Skip to content
/docsfor

Introduction

Show an HTML page from another origin inside your app. It runs in a sandboxed iframe and lays out like an inline element.

Info

The packages are not on npm yet. In the assistant-ui monorepo, depend on them with workspace:*.

A remote component is an HTML document with one script in it, rc-runtime.js. Your app renders it with <RemoteComponent>. The page gets its width from your layout and its height from its content. It inherits your fonts and colors, reads your props and calls your functions.

Any HTML works. A page with no doctype, a fragment, a page a model just wrote, a built React app. The page is the component, and its <html> element is the box in your layout.

Install

npm install @assistant-ui/remote-components-host-react @assistant-ui/remote-components-serve

The host package goes in the app that renders remotes. The serving layer goes on the server that serves them.

Quickstart

1. Write a page

Plain HTML. Props are on rc.props, function props call back into your app, and props-changed fires when they change. No build step.

widgets/rsvp.html
<!-- No doctype, no build step. The serving layer adds the doctype and the runtime. -->
<style>
  .event { font-weight: 600; }
  .when { color: var(--color-text-muted); }
  .row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
  button { font: inherit; color: inherit; height: 28px; padding: 0 10px; border: 1px solid var(--color-border); border-radius: var(--radius-sm); background: var(--color-surface); cursor: pointer; }
  button:hover { background: var(--color-surface-muted); }
  button:focus-visible { outline: 2px solid var(--focus-ring, var(--color-accent)); outline-offset: 2px; }
</style>

<div><span class="event">Team lunch</span> <span class="when">Friday, 12:30</span></div>
<div class="row">
  <button value="yes">Going</button>
  <button value="no">Can't make it</button>
</div>

<script>
  const render = ({ event = "Team lunch", when = "Friday, 12:30" }) => {
    document.querySelector(".event").textContent = event;
    document.querySelector(".when").textContent = when;
  };
  render(rc.props);
  rc.addEventListener("props-changed", () => render(rc.props));
  for (const button of document.querySelectorAll("button")) {
    button.onclick = () => rc.props.onReply(button.value);
  }
</script>

2. Serve it

The serving layer adds the doctype, the runtime tag and a Content Security Policy to every page under root. It also serves rc-runtime.js.

server.mjs
import http from "node:http";
import { remoteComponentsMiddleware } from "@assistant-ui/remote-components-serve/node";

const serve = remoteComponentsMiddleware({ root: new URL("./widgets", import.meta.url).pathname });

http
  .createServer((req, res) => serve(req, res, () => res.writeHead(404).end()))
  .listen(5173, "127.0.0.1");

Serve remotes from a different site than your app. In development, use 127.0.0.1 for remotes and localhost for the app.

3. Render it

App.tsx
import { useState } from "react";
import { RemoteComponent } from "@assistant-ui/remote-components-host-react";

export function Lunch() {
  const [reply, setReply] = useState<string | null>(null);
  return (
    <>
      <RemoteComponent
        src="http://127.0.0.1:5173/rsvp.html"
        event="Design sync"
        when="Thursday, 10:00"
        onReply={async (answer: string) => setReply(answer)}
      />
      <span>onReply({reply})</span>
    </>
  );
}

This site has no second origin to serve pages from, so the examples in these docs pass the same documents with the html prop. They run in an opaque sandboxed frame with the runtime inlined.

onReply()

Click Going. The page calls onReply, which runs in your app.

With React

The fastest path is the CLI. It builds one component file into one HTML file, with no config:

terminal
npx remote-components build rating.tsx

A React app is a page too. Add the runtime tag to its index.html and read props with useProps() from @assistant-ui/remote-components-react. In an existing Vite app, the Vite plugin turns each component file into its own page.

remotes/counter.tsx
import { useState } from "react";
import { useProps } from "@assistant-ui/remote-components-react";

export default function Counter() {
  const { label, onChange } = useProps<{ label: string; onChange?: (n: number) => Promise<void> }>();
  const [n, setN] = useState(0);
  return (
    <button
      onClick={() => {
        setN(n + 1);
        void onChange?.(n + 1);
      }}
    >
      {label}: {n}
    </button>
  );
}
App.tsx
<RemoteComponent src="http://127.0.0.1:5173/counter/" label="Clicks" />

The counter below is the same component written as a plain page.

widgets/counter.html
<button id="count"></button>
<script>
  const button = document.querySelector("#count");
  let n = 0;
  const render = () => (button.textContent = `${rc.props.label ?? "Clicks"}: ${n}`);
  render();
  rc.addEventListener("props-changed", render);
  button.onclick = () => {
    n += 1;
    render();
    void rc.props.onChange?.(n);
  };
</script>

No server

You can skip the server. <RemoteComponent html={…} /> shows a string of HTML in an opaque frame, with the runtime inlined. See Generated HTML.

Packages

Package
@assistant-ui/remote-components-runtimerc-runtime.js, the script inside the page. It exposes window.rc. No framework.
@assistant-ui/remote-components-host-react<RemoteComponent>, the provider and overlays.
@assistant-ui/remote-components-serveNormalizes any HTML into a remote. Node middleware at /node.
@assistant-ui/remote-components-reactOptional hooks for React pages.
@assistant-ui/remote-components-cliremote-components build: one component to one HTML file.
@assistant-ui/remote-components-viteOptional Vite plugin.
@assistant-ui/remote-components-protocolMessages, value codec, channel and validation. Internal.

Next: CLI builds a React component. Pages covers what a page can do. Layout covers how it fits.