Skip to content

Next.js

@virtual-frame/next provides first-class Next.js integration with server rendering. The remote page is fetched during SSR 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.

Both App Router (Server Components) and Pages Router (getServerSideProps) are supported with a single VirtualFrame import.

Installation

sh
npm install virtual-frame @virtual-frame/next @virtual-frame/store

App Router (Server Components)

In a Server Component, VirtualFrame is an async component that fetches the remote page and streams it into the response.

tsx
// app/page.tsx
import { VirtualFrame } from "@virtual-frame/next";

export default async function Page() {
  return <VirtualFrame src="http://remote:3001" />;
}

Selector Projection

tsx
<VirtualFrame src="http://remote:3001" selector="#counter-card" />

Multiple Projections from One Fetch

tsx
import { fetchVirtualFrame, VirtualFrame } from "@virtual-frame/next";

export default async function Page() {
  const frame = await fetchVirtualFrame("http://remote:3001");

  return (
    <>
      <VirtualFrame frame={frame} />
      <VirtualFrame frame={frame} selector="#counter-card" />
    </>
  );
}

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

Pages Router

Use getServerSideProps with the same VirtualFrame component:

tsx
// pages/index.tsx
import type { GetServerSideProps } from "next";
import { fetchVirtualFrame, prepareVirtualFrameProps, VirtualFrame } from "@virtual-frame/next";

export const getServerSideProps: GetServerSideProps = async () => {
  const frame = await fetchVirtualFrame("http://remote:3003");
  return {
    props: {
      fullPage: await prepareVirtualFrameProps(frame),
      counter: await prepareVirtualFrameProps(frame, { selector: "#counter-card" }),
    },
  };
};

export default function Page({ fullPage, counter }) {
  return (
    <>
      <VirtualFrame {...fullPage} />
      <VirtualFrame {...counter} />
    </>
  );
}

Remote Side

The remote is a normal Next.js app. See the Shared Store section below for how to read and write the bridged store from a "use client" component.

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

Because the store is a client-only object, fetch the frame props in a Server Component and hand them to a "use client" wrapper that owns the store:

tsx
// app/page.tsx (Server Component)
import { fetchVirtualFrame, prepareVirtualFrameProps } from "@virtual-frame/next";
import { HostFrames } from "./components/HostFrames";

export default async function Page() {
  const frame = await fetchVirtualFrame("http://remote:3001");
  return (
    <HostFrames
      frameProps={await prepareVirtualFrameProps(frame)}
      counterProps={await prepareVirtualFrameProps(frame, {
        selector: "#counter-card",
      })}
    />
  );
}
tsx
// app/components/HostFrames.tsx
"use client";

import { VirtualFrame } from "@virtual-frame/next";
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/next in a "use client" component. It's a two-mode hook — no args returns the singleton StoreProxy, a path returns a reactive value:

tsx
"use client";

import { useStore } from "@virtual-frame/next";

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 Next.js 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① Fetch remote pageDownload and parse HTML② Extract styles + bodyPrepare styles and content③ Render to responseStyled content, zero JS neededServer OutputFully styled content embedded in the page — visible on first paintEverything needed for seamless client activation is includedHTML responseClient④ ResumeActivate from server outputNo extra network requests⑤ Live projectionContent stays in syncFull interactivity enabled⑥ Shared resourcesMultiple projectionsOne source, many viewsBenefits✓ Instant paint — content visible before JS runs✓ No flash of unstyled content✓ No extra network requests on the client✓ Minimal data sent to the browserStore bridge (optional)Pass a store to VirtualFrameState syncs automaticallyChanges propagate in both directionsWorks across host and remote

Client-Side Navigation (Proxy)

When the remote app performs client-side navigation (e.g. clicking a Next.js <Link>), it needs to fetch data (such as RSC payloads) from the remote server. The proxy prop ensures these requests reach the correct server by routing them through a Next.js rewrite 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 rewrite to the host's next.config

js
// next.config.mjs (host)
const REMOTE_URL = process.env.REMOTE_URL ?? "http://localhost:3001";

/** @type {import('next').NextConfig} */
const nextConfig = {
  transpilePackages: ["virtual-frame", "@virtual-frame/next"],

  // Next 16 dev enforces an origin allowlist for client-side requests
  // (Server Actions, HMR, `/_next/*` fetches). Origin mismatch silently
  // breaks hydration and click handlers with no console error. Add every
  // loopback host your browser may use to load the page.
  allowedDevOrigins: ["localhost", "127.0.0.1"],

  async rewrites() {
    return [
      {
        source: "/__vf/:path*",
        destination: `${REMOTE_URL}/:path*`,
      },
    ];
  },
};

export default nextConfig;

Next 16 allowedDevOrigins

If you run host and remote on different loopback hosts in dev (e.g. localhost:3000 host, 127.0.0.1:3001 remote), Next 16 dev will silently kill client-side interactions on origin mismatch. Declare both hosts in allowedDevOrigins on both the host and the remote's next.config. This only affects dev; production is unaffected.

2. Pass the proxy prop to VirtualFrame

App Router (Server Component):

tsx
<VirtualFrame src="http://remote:3001" proxy="/__vf" />

App Router (with prepareVirtualFrameProps):

tsx
const frame = await fetchVirtualFrame("http://remote:3001");
const props = await prepareVirtualFrameProps(frame, { proxy: "/__vf" });
// props now includes proxy — spread it onto VirtualFrame

Pages Router:

tsx
export const getServerSideProps: GetServerSideProps = async () => {
  const frame = await fetchVirtualFrame("http://remote:3003");
  return {
    props: {
      fullPage: 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>

Works in Server Components, Client Components, and Pages Router.

PropTypeDefaultDescription
srcstringRemote URL to fetch and project
frameVirtualFrameResultPre-fetched result from fetchVirtualFrame — pass directly in an RSC, or via prepareVirtualFrameProps in both App Router and Pages Router
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 fetch / XHR rewriting (see Client-Side Navigation)
refReact.Ref<{ refresh(): void }>Exposes { refresh() }

useStore() @virtual-frame/next

Remote-side hook for use inside the projected app. Returns the singleton StoreProxy once the host has connected its MessagePort. Pass the returned proxy to the reactive-subscription hook when you want to re-render on specific paths.

tsx
// Inside the remote (projected) app
import { useStore } from "@virtual-frame/next";

const store = useStore(); // StoreProxy — singleton, stable identity

Host-side subscription is a different hook

On the host side, subscribe to specific paths with useStore(store, path) imported from @virtual-frame/react — same name, different signature, different package. See the Shared Store section above for the full host/remote pattern.

fetchVirtualFrame(url, options?)

Fetches a remote page and produces a server render result.

prepareVirtualFrameProps(frame, options?)

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