SolidStart
@virtual-frame/solid-start provides first-class SolidStart integration with server rendering. The remote page is fetched during SSR inside a route query marked with "use server" 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
npm install virtual-frame @virtual-frame/solid-start @virtual-frame/solid @virtual-frame/storeRequired app.config.ts setup
SolidStart needs a couple of hints so that Vite compiles Solid-aware packages from source (including @solidjs/router and the Virtual Frame Solid packages). This is the same recipe every third-party Solid package uses — it's not specific to Virtual Frame.
// app.config.ts
import { defineConfig } from "@solidjs/start/config";
export default defineConfig({
ssr: true,
vite: {
ssr: {
// Force Vite to transform these packages through vite-plugin-solid
// instead of Node-importing their prebuilt output. Without this,
// Node's ESM resolver bypasses the `solid` export condition and
// loads the browser build on the server, which throws
// "Client-only API called on the server side".
noExternal: ["@solidjs/router", "@virtual-frame/solid", "@virtual-frame/solid-start"],
resolve: {
// `solid` must come first so packages that expose a `solid`
// export condition (pointing at raw source JSX/TSX) are picked
// up and compiled in SSR mode by vite-plugin-solid.
conditions: ["solid", "node", "import", "module", "default"],
},
},
resolve: {
// Same treatment on the client so SSR and browser builds use the
// same compilation pipeline — otherwise hydration templates
// emitted by SSR won't line up with those rebuilt in the client
// and you'll hit "Failed attempt to create new DOM elements
// during hydration".
conditions: ["solid", "browser", "import", "module", "default"],
},
},
});Why is this needed?
Solid's compiler only emits hydration-safe code when a component is compiled inside a hydration boundary — i.e., as part of the host app's build, not as a prebuilt library chunk. That's why every Solid ecosystem package (including @solidjs/router, @solidjs/meta, etc.) ships JSX source under the solid export condition and relies on the consuming app to compile it. The noExternal + resolve.conditions lines above tell Vite to do exactly that.
Route Query (Server Rendering)
Create a query(..., "frames") function that runs "use server" to fetch the remote page during SSR. The function runs on the server, keeping node-html-parser out of the client bundle. Expose it as the route's preload so data is ready before the component mounts, and consume it with createAsync().
// src/routes/index.tsx
import { query, createAsync, type RouteDefinition } from "@solidjs/router";
import { Show } from "solid-js";
import { VirtualFrame } from "@virtual-frame/solid-start";
import { fetchVirtualFrame, prepareVirtualFrameProps } from "@virtual-frame/solid-start/server";
const REMOTE_URL = process.env.REMOTE_URL ?? "http://localhost:3015";
const getFrames = query(async () => {
"use server";
const frame = await fetchVirtualFrame(REMOTE_URL);
return {
frame: await prepareVirtualFrameProps(frame),
};
}, "frames");
export const route = {
preload: () => getFrames(),
} satisfies RouteDefinition;
export default function Home() {
const data = createAsync(() => getFrames());
return <Show when={data()}>{(frames) => <VirtualFrame {...frames().frame} />}</Show>;
}Selector Projection
Project only a specific part of the remote page:
const getFrames = query(async () => {
"use server";
const frame = await fetchVirtualFrame(REMOTE_URL);
return {
frame: await prepareVirtualFrameProps(frame, {
selector: "#counter-card",
}),
};
}, "frames");Multiple Projections from One Fetch
Fetch once, display multiple sections — every <VirtualFrame> instance targeting the same src shares a single hidden iframe:
const getFrames = query(async () => {
"use server";
const frame = await fetchVirtualFrame(REMOTE_URL);
return {
fullFrame: await prepareVirtualFrameProps(frame),
counterFrame: await prepareVirtualFrameProps(frame, {
selector: "#counter-card",
}),
};
}, "frames");
export default function Home() {
const data = createAsync(() => getFrames());
return (
<Show when={data()}>
{(frames) => (
<>
<VirtualFrame {...frames().fullFrame} />
<VirtualFrame {...frames().counterFrame} />
</>
)}
</Show>
);
}Remote Side
The remote is a normal SolidStart app. Add the bridge script to src/entry-client.tsx — it auto-initialises when loaded inside an iframe and is a no-op when loaded standalone:
// src/entry-client.tsx
import { mount, StartClient } from "@solidjs/start/client";
// Virtual Frame bridge — auto-initialises inside an iframe.
import "virtual-frame/bridge";
mount(() => <StartClient />, document.getElementById("app")!);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 via Solid signals 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
// src/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
// src/routes/index.tsx
import { createAsync } from "@solidjs/router";
import { Show } from "solid-js";
import { VirtualFrame, useStore } from "@virtual-frame/solid-start";
import { store } from "../store";
export default function Home() {
const data = createAsync(() => getFrames());
// Subscribe to a path — returns a Solid signal accessor.
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>
<Show when={data()}>
{(frames) => (
<>
{/* Any VirtualFrame that receives store= joins the same sync bridge. */}
<VirtualFrame {...frames().fullFrame} store={store} />
<VirtualFrame {...frames().counterFrame} store={store} />
</>
)}
</Show>
</>
);
}- 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 signalsvf-store:ready, the component opens aMessageChannel, transfers one port to the iframe, and callsconnectPort()on the host side. Multiple<VirtualFrame>instances sharing the samesrcshare 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/solid-start/store (singleton that connects to the incoming MessagePort) together with useStore from @virtual-frame/solid-start (reactive subscription):
// src/routes/index.tsx (remote)
import { useStore as useRemoteStore } from "@virtual-frame/solid-start/store";
import { useStore } from "@virtual-frame/solid-start";
export default function Home() {
const store = useRemoteStore();
const count = useStore<number>(store, ["count"]);
return (
<div id="counter-card">
<div>{count() ?? 0}</div>
<button onClick={() => (store["count"] = (count() ?? 0) + 1)}>Increment</button>
<button onClick={() => (store["count"] = 0)}>Reset</button>
</div>
);
}Two imports, two different functions, both named useStore:
| Import | Purpose |
|---|---|
useStore from @virtual-frame/solid-start/store | Remote singleton. Returns the StoreProxy for the remote app. Sets up the MessagePort bridge on first call. |
useStore from @virtual-frame/solid-start | Reactive subscription. Takes a StoreProxy + path and returns a Solid signal accessor. |
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 useRemoteStore() returns a plain in-memory store, so the page still works as a standalone SolidStart 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
postMessageboundary — 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
storeto every frame that targets the same remote.
How Server Rendering Works
Client-Side Navigation (Proxy)
When the remote app performs client-side navigation, it needs to fetch data from the remote server. The proxy option ensures these requests reach the correct server by routing them through a dev proxy on the host.
Without proxy, client-side navigation in the remote app will fail with network errors whenever the host and remote run on different origins.
1. Add a dev proxy to the host's SolidStart config
// app.config.ts (host)
import { defineConfig } from "@solidjs/start/config";
const REMOTE_URL = process.env.REMOTE_URL ?? "http://localhost:3015";
export default defineConfig({
server: {
devProxy: {
"/__vf": {
target: REMOTE_URL,
changeOrigin: true,
rewrite: (path) => path.replace(/^\/__vf/, ""),
},
},
},
});2. Pass the proxy option
const getFrames = query(async () => {
"use server";
const frame = await fetchVirtualFrame(REMOTE_URL);
return {
frame: await prepareVirtualFrameProps(frame, { proxy: "/__vf" }),
};
}, "frames");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>
Solid component that displays server-fetched content and resumes live mirroring.
| Prop | Type | Default | Description |
|---|---|---|---|
src | string | — | Remote URL to fetch and project |
selector | string | — | CSS selector for partial projection |
isolate | "open" | "closed" | "open" | Shadow DOM mode |
streamingFps | number | Record<string, number> | — | Canvas/video streaming FPS |
store | StoreProxy | — | Shared store for cross-frame state sync |
proxy | string | — | Same-origin proxy prefix for client-side navigation |
_vfHtml | string | — | SSR HTML from prepareVirtualFrameProps() |
useStore(store, path?)
Subscribes to a store path and returns a Solid signal accessor.
import { useStore } from "@virtual-frame/solid-start";
const count = useStore<number>(store, ["count"]); // signal accessor
// use as count() in JSXuseStore() (remote-side)
Remote-side helper. Returns the shared store singleton and sets up the MessagePort bridge. Import from @virtual-frame/solid-start/store.
import { useStore as useRemoteStore } from "@virtual-frame/solid-start/store";
const store = useRemoteStore();fetchVirtualFrame(url, options?)
Server-only. Fetches a remote page and produces a server render result. Import from @virtual-frame/solid-start/server.
prepareVirtualFrameProps(frame, options?)
Server-only. Converts a server render result into serialisable props for <VirtualFrame>. Returns a Promise — always await it.
| Option | Type | Default | Description |
|---|---|---|---|
selector | string | — | CSS selector for partial projection |
isolate | "open" | "closed" | "open" | Shadow DOM mode |
proxy | string | — | Same-origin proxy prefix for client-side navigation |
Examples
- SolidStart example —
pnpm example:solid-start