Skip to content

React Router

@virtual-frame/react-router provides first-class React Router v7 integration with server rendering. The remote page is fetched during SSR inside a route loader and embedded in the response — the user sees styled content on first paint with zero layout shift, and the client resumes live updates without an extra network request.

Installation

sh
npm install virtual-frame @virtual-frame/react-router @virtual-frame/store

Route Loader (Server Rendering)

Use a route loader to fetch the remote page during SSR. The loader runs on the server, keeping node-html-parser out of the client bundle.

tsx
// app/routes/home.tsx
import { fetchVirtualFrame, prepareVirtualFrameProps } from "@virtual-frame/react-router/server";
import { VirtualFrame } from "@virtual-frame/react-router";
import type { Route } from "./+types/home";

const REMOTE_URL = process.env.REMOTE_URL ?? "http://localhost:3007";

export async function loader() {
  const frame = await fetchVirtualFrame(REMOTE_URL);
  return await prepareVirtualFrameProps(frame);
}

export default function HostPage({ loaderData }: Route.ComponentProps) {
  return <VirtualFrame {...loaderData} />;
}

Selector Projection

Project only a specific part of the remote page:

tsx
export async function loader() {
  const frame = await fetchVirtualFrame(REMOTE_URL);
  return await prepareVirtualFrameProps(frame, {
    selector: "#counter-card",
  });
}

Multiple Projections from One Fetch

Fetch once, display multiple sections — both VirtualFrame instances share a single hidden iframe:

tsx
export async function loader() {
  const frame = await fetchVirtualFrame(REMOTE_URL);
  return {
    fullFrame: await prepareVirtualFrameProps(frame),
    counterFrame: await prepareVirtualFrameProps(frame, {
      selector: "#counter-card",
    }),
  };
}

export default function HostPage({ loaderData }: Route.ComponentProps) {
  const { fullFrame, counterFrame } = loaderData;
  return (
    <>
      <VirtualFrame {...fullFrame} />
      <VirtualFrame {...counterFrame} />
    </>
  );
}

See the Shared Store section below for host + remote bridge wiring.

Remote Side

The remote is a normal React Router app. Add the bridge script to your root — it auto-initialises when loaded inside an iframe and is a no-op when loaded standalone:

tsx
// app/root.tsx
import "virtual-frame/bridge";

See the Shared Store section below for how to read and write the bridged store from the remote.

Shared Store

A shared store keeps state in sync between the host app and the remote app (including every projected frame) over a MessagePort bridge. Writes on either side propagate to the other automatically, and every useStore(...) subscription re-renders when the underlying value changes.

The store lives in the host — the remote connects to it at runtime via the hidden iframe VirtualFrame mounts. You do not duplicate the store on the remote: the remote-side useStore() returns a proxy that forwards reads and writes across the port.

1. Create the store on the host

ts
// app/store.ts
import { createStore } from "@virtual-frame/store";

export const store = createStore();
store.count = 0;

createStore() returns a plain reactive object. Assign initial values directly — nested objects and arrays are supported. Paths are addressed as string arrays: ["count"], ["user", "name"], ["items", 0].

2. Pass the store to <VirtualFrame> on the host

Fetch the frame props in a loader and hand them to a client component that owns the store:

tsx
// app/routes/home.tsx
import { fetchVirtualFrame, prepareVirtualFrameProps } from "@virtual-frame/react-router/server";
import { HostFrames } from "../components/HostFrames";
import type { Route } from "./+types/home";

const REMOTE_URL = process.env.REMOTE_URL ?? "http://localhost:3007";

export async function loader() {
  const frame = await fetchVirtualFrame(REMOTE_URL);
  return {
    fullFrame: await prepareVirtualFrameProps(frame),
    counterFrame: await prepareVirtualFrameProps(frame, {
      selector: "#counter-card",
    }),
  };
}

export default function HostPage({ loaderData }: Route.ComponentProps) {
  const { fullFrame, counterFrame } = loaderData;
  return <HostFrames frameProps={fullFrame} counterProps={counterFrame} />;
}
tsx
// app/components/HostFrames.tsx
import { VirtualFrame } from "@virtual-frame/react-router";
import { useStore } from "@virtual-frame/react";
import { store } from "../store";

export function HostFrames({ frameProps, counterProps }) {
  // Subscribe to a path — returns the current value, re-renders on change.
  const count = useStore<number>(store, ["count"]);

  return (
    <>
      <p>Host count: {count ?? 0}</p>
      <button onClick={() => (store.count = (count ?? 0) + 1)}>Increment from host</button>
      <button onClick={() => (store.count = 0)}>Reset</button>

      {/* Any VirtualFrame that receives store= joins the same sync bridge. */}
      <VirtualFrame {...frameProps} store={store} />
      <VirtualFrame {...counterProps} store={store} />
    </>
  );
}
  • Host reads/writes are direct: store.count operates on the host's in-memory object — no serialisation, no round-trip.
  • Passing store={store} wires up the bridge: when the hidden iframe loads and the remote signals vf-store:ready, the component opens a MessageChannel, transfers one port to the iframe, and calls connectPort() on the host side. Multiple <VirtualFrame> instances sharing the same src share one iframe and one port — the store is bridged exactly once.

3. Consume the store on the remote

On the remote, use useStore from @virtual-frame/react-router. It's a two-mode hook — no args returns the singleton StoreProxy, a path returns a reactive value:

tsx
import { useStore } from "@virtual-frame/react-router";

function Counter() {
  const store = useStore(); // StoreProxy singleton
  const count = useStore<number>(["count"]); // reactive value at path

  return <button onClick={() => (store.count = (count ?? 0) + 1)}>Count: {count ?? 0}</button>;
}
CallReturnsPurpose
useStore()StoreProxyRemote singleton. Connects to the host store on first call.
useStore(["count"])TReactive subscription. Re-renders the component on value change.

Standalone fallback

When the remote page is loaded directly in the browser (not through a VirtualFrame), there is no host and no port. In that case useStore() returns a plain in-memory store, so the page still works as a standalone React Router app. Writes stay local; reads return whatever was last written.

Tips

  • Initialise on the host, not the remote. The host's values are the source of truth on first connect. Anything the remote writes before the port is open is kept local until the bridge finishes handshaking.
  • Keep values serialisable. Values cross a postMessage boundary — prefer plain objects, arrays, primitives. No class instances, functions, or DOM nodes.
  • Namespace per feature. For multiple features in one app, group keys under stable prefixes (["cart", "items"], ["auth", "user"]).
  • One store per remote URL is typical. Pass the same store to every frame that targets the same remote.

How Server Rendering Works

Server (route loader)1. Fetch remote pageDownload and parse HTML2. Extract styles + bodyPrepare styles and content3. Render to responseStyled content, zero JS neededLoader OutputSSR HTML wrapped in declarative shadow DOM — visible on first paintSerialised and delivered as loader dataHTML responseClient4. ResumeHidden iframe loads remote appBridge auto-initialises5. Live projectionContent stays in syncFull interactivity enabled6. Shared resourcesMultiple projectionsOne iframe, many viewsBenefitsInstant paint — content visible before JS runsNo flash of unstyled contentNo extra network requests on the clientRef-counted shared iframes across projectionsStore bridge (optional)Pass a store to VirtualFrameState syncs automatically via MessagePortChanges propagate in both directionsWorks across host and remote

Client-Side Navigation (Proxy)

When the remote app performs client-side navigation, it needs to fetch data from the remote server. The proxy prop ensures these requests reach the correct server by routing them through a server middleware on the host.

Without proxy, client-side navigation in the remote app will fail with network errors. This is required whenever the host and remote run on different origins.

1. Add a proxy to the host's Vite config

ts
// vite.config.ts (host)
import { reactRouter } from "@react-router/dev/vite";
import { defineConfig } from "vite";

const REMOTE_URL = process.env.REMOTE_URL ?? "http://localhost:3007";

export default defineConfig({
  server: {
    proxy: {
      "/__vf": {
        target: REMOTE_URL,
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/__vf/, ""),
      },
    },
  },
  plugins: [reactRouter()],
});

2. Pass the proxy prop

tsx
export async function loader() {
  const frame = await fetchVirtualFrame(REMOTE_URL);
  return await prepareVirtualFrameProps(frame, { proxy: "/__vf" });
}

TIP

The proxy prefix (/__vf) is a convention — you can use any path that doesn't conflict with your host app's routes. For multiple remotes, use a different prefix for each.

API Reference

<VirtualFrame>

Client component that displays server-fetched content and resumes live mirroring.

PropTypeDefaultDescription
srcstringRemote URL to fetch and project
selectorstringCSS selector for partial projection
isolate"open" | "closed""open"Shadow DOM mode
streamingFpsnumber | Record<string, number>Canvas/video streaming FPS
storeStoreProxyShared store for cross-frame state sync
proxystringSame-origin proxy prefix for client-side navigation
refReact.RefExposes { refresh() }

useStore(selector?)

Remote-side hook. Returns the store instance or a reactive value at a path.

tsx
const store = useStore(); // store instance
const count = useStore<number>(["count"]); // reactive value

fetchVirtualFrame(url, options?)

Server-only. Fetches a remote page and produces a server render result. Import from @virtual-frame/react-router/server.

prepareVirtualFrameProps(frame, options?)

Server-only. Converts a server render result into serialisable props for <VirtualFrame>. Returns a Promise — always await it.

OptionTypeDefaultDescription
selectorstringCSS selector for partial projection
isolate"open" | "closed""open"Shadow DOM mode
proxystringSame-origin proxy prefix for client-side navigation

Examples