SvelteKit
@virtual-frame/sveltekit provides first-class SvelteKit integration with server rendering. The remote page is fetched during SSR inside a +page.server.ts load function 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/sveltekit @virtual-frame/svelte @virtual-frame/storeLoad Function (Server Rendering)
Create a +page.server.ts load function to fetch the remote page during SSR. The function runs on the server, keeping node-html-parser out of the client bundle.
// src/routes/+page.server.ts
import { fetchVirtualFrame, prepareVirtualFrameProps } from "@virtual-frame/sveltekit/server";
const REMOTE_URL = process.env["REMOTE_URL"] ?? "http://localhost:3013";
export const load = async () => {
const frame = await fetchVirtualFrame(REMOTE_URL);
return {
frame: await prepareVirtualFrameProps(frame),
};
};<!-- src/routes/+page.svelte -->
<script lang="ts">
import { VirtualFrame } from "@virtual-frame/sveltekit";
import type { PageData } from "./$types";
let { data }: { data: PageData } = $props();
</script>
<VirtualFrame {...data.frame} />Selector Projection
Project only a specific part of the remote page:
// src/routes/+page.server.ts
export const load = async () => {
const frame = await fetchVirtualFrame(REMOTE_URL);
return {
frame: await prepareVirtualFrameProps(frame, {
selector: "#counter-card",
}),
};
};Multiple Projections from One Fetch
Fetch once, display multiple sections — both <VirtualFrame> instances share a single hidden iframe:
// src/routes/+page.server.ts
export const load = async () => {
const frame = await fetchVirtualFrame(REMOTE_URL);
return {
fullFrame: await prepareVirtualFrameProps(frame),
counterFrame: await prepareVirtualFrameProps(frame, {
selector: "#counter-card",
}),
};
};<!-- src/routes/+page.svelte -->
<script lang="ts">
import { VirtualFrame } from "@virtual-frame/sveltekit";
import type { PageData } from "./$types";
let { data }: { data: PageData } = $props();
</script>
<VirtualFrame {...data.fullFrame} />
<VirtualFrame {...data.counterFrame} />With Shared Store
Create a store and pass it to <VirtualFrame>:
// src/lib/store.ts
import { createStore } from "@virtual-frame/store";
export const store = createStore();
store["count"] = 0;<!-- src/routes/+page.svelte -->
<script lang="ts">
import { VirtualFrame, useStore } from "@virtual-frame/sveltekit";
import { store } from "$lib/store";
import type { PageData } from "./$types";
let { data }: { data: PageData } = $props();
const count = useStore<number>(store, ["count"]);
</script>
<p>Count: {$count ?? 0}</p>
<button onclick={() => store["count"]++}>Increment</button>
<VirtualFrame {...data.fullFrame} {store} />
<VirtualFrame {...data.counterFrame} {store} />Remote Side
The remote is a normal SvelteKit app. Import the bridge script from src/hooks.client.ts — it auto-initialises when loaded inside an iframe and is a no-op when loaded standalone:
// src/hooks.client.ts
import "virtual-frame/bridge";Use useStore from @virtual-frame/sveltekit/store (remote-side singleton) together with useStore from @virtual-frame/sveltekit (reactive subscriptions):
<script lang="ts">
import { useStore as useRemoteStore } from "@virtual-frame/sveltekit/store";
import { useStore } from "@virtual-frame/sveltekit";
const store = useRemoteStore();
const count = useStore<number>(store, ["count"]);
</script>
<button onclick={() => store["count"]++}>Count: {$count ?? 0}</button>| Call | Returns | Purpose |
|---|---|---|
useRemoteStore() | StoreProxy | Store instance (connects to host store) |
useStore(store, ["count"]) | Readable<T> | Reactive value at path |
How Server Rendering Works
Shared Store
A shared store keeps state in sync between the host app and the remote app (including all projected frames) 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 the VirtualFrame component mounts. You do not need to duplicate the store on the remote: useRemoteStore() returns a proxy that forwards reads and writes across the port.
1. Create the store on the host
// src/lib/store.ts (host)
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/+page.svelte (host) -->
<script lang="ts">
import { VirtualFrame, useStore } from "@virtual-frame/sveltekit";
import { store } from "$lib/store";
import type { PageData } from "./$types";
let { data }: { data: PageData } = $props();
// Subscribe to a path — returns a Svelte `Readable` you can use with `$`.
const count = useStore<number>(store, ["count"]);
</script>
<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 {...data.fullFrame} {store} />
<VirtualFrame {...data.counterFrame} {store} />Two things to notice:
- Host reads/writes are direct:
store["count"]and$countoperate on the host's in-memory object — no serialisation, no round-trip. - Passing
{store}to<VirtualFrame>wires up the bridge: when the hidden iframe finishes loading and the remote signalsvf-store:ready, the component opens aMessageChannel, transfers one port to the iframe, and callsconnectPort()on the host side. When multiple<VirtualFrame>instances share the samesrc, they share one iframe and one port — the store is bridged exactly once.
3. Consume the store on the remote
On the remote, use the singleton helper useStore from @virtual-frame/sveltekit/store. It connects to the incoming MessagePort on first call and returns a StoreProxy that behaves like a plain reactive object:
<!-- src/routes/+page.svelte (remote) -->
<script lang="ts">
import { useStore as useRemoteStore } from "@virtual-frame/sveltekit/store";
import { useStore } from "@virtual-frame/sveltekit";
const store = useRemoteStore();
const count = useStore<number>(store, ["count"]);
</script>
<div id="counter-card">
<div class="counter">{$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/sveltekit/store | Remote singleton. Returns the StoreProxy for the remote app. Sets up the MessagePort bridge on first call. |
useStore from @virtual-frame/sveltekit | Reactive subscription. Takes a StoreProxy + path and returns a Svelte Readable<T>. Use with the $ prefix in templates. |
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 your page still works as a standalone SvelteKit 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, so prefer plain objects, arrays, primitives — no class instances, functions, or DOM nodes. - Namespace per feature. For multiple independent features in one app, group keys under stable prefixes (
["cart", "items"],["auth", "user"]) to keep paths predictable. - One store per remote URL is typical. If you project the same remote into several frames, pass the same
storeto each — they all share the bridge. If you have two distinct remotes, create two stores.
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 Vite config
// vite.config.ts (host)
import { sveltekit } from "@sveltejs/kit/vite";
import { defineConfig } from "vite";
const REMOTE_URL = process.env["REMOTE_URL"] ?? "http://localhost:3013";
export default defineConfig({
plugins: [sveltekit()],
server: {
proxy: {
"/__vf": {
target: REMOTE_URL,
changeOrigin: true,
rewrite: (path) => path.replace(/^\/__vf/, ""),
},
},
},
});2. Pass the proxy option
// src/routes/+page.server.ts
export const load = async () => {
const frame = await fetchVirtualFrame(REMOTE_URL);
return {
frame: 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>
Svelte 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 Svelte Readable.
import { useStore } from "@virtual-frame/sveltekit";
const count = useStore<number>(store, ["count"]); // readable store
// use as $count in templatesuseStore() (remote-side)
Remote-side helper. Returns the shared store singleton and sets up the MessagePort bridge. Import from @virtual-frame/sveltekit/store.
import { useStore as useRemoteStore } from "@virtual-frame/sveltekit/store";
const store = useRemoteStore();fetchVirtualFrame(url, options?)
Server-only. Fetches a remote page and produces a server render result. Import from @virtual-frame/sveltekit/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
- SvelteKit example —
pnpm example:sveltekit