Skip to content

Remote Components

Remote Components let CMS users — with AI assistance — author React components for the storefront from inside the CMS, save them to the database, and have the storefront render them server-side with real SDK data without a redeploy.

A “Remote Component” is a React component whose source (TSX) lives in the CMS database, is compiled to a self-contained IIFE module at save time, and is fetched and evaluated by the storefront at request time — exactly like a built-in component, but ops-free.

The loop at a glance

Component Builder — Zen mode

  1. Author in the Component Builder: describe the component in natural language, or paste an image, or point at a Figma node.
  2. Compile in-browser via esbuild-wasm. Output is a self-contained IIFE (no ESM imports in the output).
  3. Save to the component_source table. A matching content_models row is upserted with the generated field schema.
  4. Add to a page: the new component appears in the CMS palette like any built-in.
  5. Deliver: the CMS delivery API returns a componentMap alongside the page data, pointing at HMAC-signed URLs that serve the compiled module.
  6. Render: the storefront’s RenderCmsContent falls back to dynamic resolution for any component not in its static registry — it fetches the module, evaluates it with new Function() + server-side externals (including the real getSdk), and React renders it.

End-to-end flow

┌────────────────────────────── CMS ──────────────────────────────┐
│ │
│ Component Builder (React) │
│ ├─ Zen input / chat / code / preview │
│ ├─ esbuild-wasm compiles TSX → IIFE (client-side) │
│ └─ Save: POST /api/component-source/:id/save-with-model │
│ │ │
│ ▼ │
│ ┌─────────────┐ ┌─────────────────┐ │
│ │ D1 tables │ │ AI routes │ │
│ │ component_ │◀──▶│ /api/ai/* │ │
│ │ source + │ │ (Workers AI OR │ │
│ │ content_ │ │ Claude) │ │
│ │ models │ └─────────────────┘ │
│ └─────────────┘ │
│ │
│ Delivery API (public, scoped by delivery token) │
│ GET /api/v1/pages/by-path/* │
│ → response now includes `componentMap` │
│ │
│ GET /api/v1/compiled-components/:id?exp=&sig= │
│ → returns the compiled IIFE (HMAC-signed URL, 5 min TTL) │
│ │
└─────────────────────────────────────────────────────────────────┘
│ page + componentMap
┌────────────────────────── Storefront ────────────────────────────┐
│ │
│ connectCmsPage (server component) │
│ └─ RenderCmsContent (async server component) │
│ ├─ static registry hit? → render as today │
│ └─ miss + componentMap[name] set? │
│ └─ resolveServerComponent() │
│ 1. fetch(signed URL) │
│ 2. new Function('__CMS_EXTERNALS__', code) │
│ 3. call with SERVER_EXTERNALS │
│ 4. returns React component (maybe async) │
│ │
│ SERVER_EXTERNALS: │
│ - react, react/jsx-runtime │
│ - next/image, next/link │
│ - @/sdk → { getSdk } (real SDK — fetches products, cart, …) │
│ │
└──────────────────────────────────────────────────────────────────┘

The Component Builder

The CMS ships with a dedicated page for authoring Remote Components. It has four interactive modes:

Zen mode (default)

A minimal input floats on a gradient. Press enter → a rotating “thinking” phrase cycles while the AI streams + compilation runs. When compilation succeeds, the preview fills the viewport.

Zen mode — input state

Chat + Code + Preview

Toggle off Zen or collapse any of the three panels via the toolbar. The code panel is a full Monaco editor with a maximize button that expands it to the whole viewport.

Three-panel builder with live preview

Image input

Paperclip icon next to the send button, or paste a screenshot directly into the textarea. Images are uploaded to R2 via POST /api/assets/ and passed to the AI as native image blocks (Anthropic) or flattened to [image attached: URL] notes (Workers AI).

Figma node

Paste a Figma node URL (?node-id=...). The CMS fetches the node JSON + a PNG via the Figma REST API and passes both to Claude. Requires ANTHROPIC_API_KEY and FIGMA_ACCESS_TOKEN.

Remote Components library

The “Remote Components” sidebar entry lists every saved component. Click any entry to reopen it in the Component Builder — the editor hydrates with its source + schema + compiled code so you can iterate with the AI or hand-edit in Monaco.

Remote Components list

Data model

Two tables back the feature.

component_source (new)

CREATE TABLE component_source (
id TEXT PRIMARY KEY,
content_model_id TEXT REFERENCES content_models(id) ON DELETE SET NULL,
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
environment_id TEXT NOT NULL REFERENCES environments(id) ON DELETE CASCADE,
name TEXT NOT NULL,
source_code TEXT NOT NULL, -- raw TSX (editable)
compiled_code TEXT, -- IIFE output (served to storefront)
compiled_hash TEXT,
status TEXT NOT NULL DEFAULT 'draft' CHECK(status IN ('draft','compiled','error')),
error_log TEXT,
version INTEGER NOT NULL DEFAULT 1,
created_by TEXT REFERENCES users(id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(space_id, environment_id, name)
);

Migration: src/db/migrations/023_component_source.sql. Also created at cold start via CREATE TABLE IF NOT EXISTS in src/server/index.ts.

content_models (existing, reused)

Each Remote Component has a 1:1 link to a content_models row. The content model defines the field schema (what props the CMS editor UI shows); the component source defines how it renders. Built-in components have a schema in content_models but their code lives in the storefront repo. Remote Components have both.

Backend routes

AI generation

MethodPathPurpose
POST/api/ai/generate-componentSSE stream of AI-generated schema + TSX. Uses Anthropic if ANTHROPIC_API_KEY is set, otherwise Workers AI.
POST/api/ai/generate-from-figmaVariant that accepts { figmaUrl }, fetches the node + image from Figma, and calls Claude with both.

The AI is instructed to emit both outputs between explicit markers:

------COPY TO schema.json
[ ... JSON array ... ]
------END COPY schema.json
------COPY TO component.tsx
import { getSdk } from "@/sdk";
export default async function MyComponent(...) { ... }
------END COPY component.tsx

This is robust against reasoning models (Gemma 4) that emit multiple intermediate blocks — the client-side extractor matches the marker format exactly.

CRUD

MethodPathPurpose
GET/api/component-sourceList (search, limit, offset)
GET/api/component-source/:idGet single row + joined content_model (so the editor can hydrate fields)
POST/api/component-sourceCreate
PUT/api/component-source/:idUpdate source (bumps version, flips status to draft)
PUT/api/component-source/:id/compiledStore compiled IIFE (flips status to compiled)
POST/api/component-source/:id/save-with-modelAtomic upsert of both the component source AND the linked content model in one DB.batch()
DELETE/api/component-source/:idDelete
GET/api/component-source/preview-mapReturns a signed-URL componentMap for the CMS preview iframe

Delivery

MethodPathPurpose
GET/api/v1/pages/by-path/*Published/preview page data. Now includes componentMap for any dynamic components on the page.
GET/api/v1/compiled-components/:id?exp=&sig=Serves the compiled IIFE. HMAC-signed URLs; no bearer token needed.

Compilation

Where it happens

Client-side, in the browser, using esbuild-wasm loaded from unpkg.com/esbuild-wasm@0.28.0/esbuild.wasm. Compilation is instant (~50-200ms after the WASM is warm) and recompiles on every edit in Monaco (debounced 300ms).

Source: apps/cms/src/client/lib/compiler.ts.

The externals plugin

The hardest part of this whole system is making sure the compiled module has no ESM imports in its output — otherwise it can’t be evaluated with new Function(). esbuild’s built-in external: [...] emits import statements, which is fatal inside a function body.

Instead, we use a custom esbuild plugin that rewrites imports during compilation:

const cmsExternalsPlugin = {
name: "cms-externals",
setup(build) {
const filter = /^(react|react\/jsx-runtime|next\/image|next\/link|@\/sdk)$/;
build.onResolve({ filter }, (args) => ({
path: args.path,
namespace: "cms-external",
}));
build.onLoad({ filter: /.*/, namespace: "cms-external" }, (args) => ({
contents: `module.exports = __CMS_EXTERNALS__["${args.path}"]`,
loader: "js",
}));
},
};

Output (abbreviated):

var __module__ = (() => {
var require_react = /* ... */ module.exports = __CMS_EXTERNALS__["react"];
var require_sdk = /* ... */ module.exports = __CMS_EXTERNALS__["@/sdk"];
// ... component code ...
return { default: ProductShowcase };
})();

At runtime, the storefront passes in __CMS_EXTERNALS__ with the real React, the real getSdk, etc. The IIFE resolves them and returns the exported component.

Build config

OptionValueWhy
formatiifeMust be wrappable in a function body
globalName__module__What the IIFE assigns to
jsxautomaticReact 19’s automatic JSX runtime
targetes2022Matches the storefront’s Next.js build
bundletrueNeeded for the externals plugin
writefalseKeep output in memory
minifyfalseBetter error messages in dev

Schema ↔ code reconciliation

The AI sometimes generates a prop in the TSX Props interface but forgets to add it to the schema. apps/cms/src/client/lib/schema-reconciler.ts parses the Props interface from the source code (regex + balanced-brace splitter) and auto-adds any missing fields to the schema, inferring the field type from the TS type (stringtext/textarea/richtext by name heuristic, booleanboolean, { alt, desktop, mobile }image, { label, link }button, literal unions → select, etc.). Reserved props (className, aboveFold, slotClasses, children) are always skipped.

Storefront runtime

Three files in the demo storefront make dynamic resolution work. They’re ported from the POC and wired into the existing RenderCmsContent pipeline so the built-in components keep behaving exactly as before.

runtime/server-externals.ts

The dependency-injection map. Contains the real React, Next.js helpers, and (critically) getSdk:

import * as React from 'react';
import * as ReactJSXRuntime from 'react/jsx-runtime';
import Image from 'next/image';
import Link from 'next/link';
import { getSdk } from '@/sdk';
export const SERVER_EXTERNALS: Record<string, unknown> = {
'react': React,
'react/jsx-runtime': ReactJSXRuntime,
'react/jsx-dev-runtime': ReactJSXRuntime,
'next/image': { default: Image, __esModule: true },
'next/link': { default: Link, __esModule: true },
'@/sdk': { getSdk, __esModule: true },
};

runtime/module-resolver.ts

Fetches, evaluates, caches:

export async function resolveServerComponent(componentName, moduleUrl) {
const cached = moduleCache.get(componentName);
if (cached && Date.now() - cached.timestamp < MODULE_TTL) return cached.component;
const code = await (await fetch(moduleUrl, { cache: 'no-store' })).text();
const factory = new Function(
`"use strict"; var __CMS_EXTERNALS__ = arguments[0]; ${code}; return __module__;`,
);
const mod = factory(SERVER_EXTERNALS);
moduleCache.set(componentName, { component: mod.default, timestamp: Date.now() });
return mod.default;
}

In-memory cache has a 60-second TTL — fine for the current demo. For serverless cold-start resilience, wrap with KV or Redis.

RenderCmsContent fallback

The only change to the existing renderer is turning it into an async server component and adding a fallback branch:

let Component = components[component]; // static registry lookup
if (!Component && componentMap?.[component]) {
try {
Component = await resolveServerComponent(component, componentMap[component].url);
} catch (error) {
// dev: show a red box with the error; prod: render null
}
}

componentMap is threaded through recursion so nested components resolve too.

Live preview protocol

The CMS editor’s iframe preview needs the componentMap to render dynamic components during editing — without exposing a CMS token to the browser.

CMS side (PreviewFrame.tsx)

The hook fetches /api/component-source/preview-map periodically (every 4 minutes so signed URLs stay fresh) and includes the map in every postMessage:

iframe.contentWindow.postMessage(
{ type: 'alokai-cms:preview', page: data, componentMap },
'*',
);

Storefront side (LivePreviewWrapper.tsx)

Receives the map and injects it into the page data before calling the server action that re-renders:

if (event.data?.type === 'alokai-cms:preview') {
const { page, componentMap } = event.data;
if (componentMap) page.componentMap = componentMap;
debouncedRerender(page);
}

Security model

Compiled modules are public-but-signed

The /api/v1/compiled-components/:id endpoint does not require a bearer token. Instead it requires a valid HMAC signature in the query string:

/api/v1/compiled-components/<id>?exp=<unix-seconds>&sig=<base64url-hmac-sha256>
  • Signed payload: ${componentId}.${exp}
  • Secret: AUTH_JWT_SECRET (a dev fallback is used only when the env var isn’t set — set it in production)
  • TTL: 5 minutes

Verification: apps/cms/src/server/middleware/delivery-auth.ts.

Why: compiled bundles need to be fetched by anonymous server-side SSR — threading a bearer token through the Next.js server-action boundary is fragile, and exposing a token to the browser is worse. Signed URLs with a short TTL solve both: can’t be forged, can’t be replayed for long, and no secret ever leaves the CMS.

What gets evaluated

new Function() runs the compiled code. The externals map is the only attack surface — components can only reach what’s in SERVER_EXTERNALS. That deliberately excludes fetch, require, process, globalThis, filesystem, etc. If the component tries to import fs, esbuild fails at compile time because fs isn’t in the plugin’s allowlist.

Trust model

This pattern is safe when component authors are as trusted as developers who can push to the storefront repo — same blast radius. For a multi-tenant scenario with untrusted authors, layer on AST-based source validation and an opt-in publishing workflow.

How a Remote Component is written

A typical server component looks identical to the built-in ProductList:

import { getSdk } from "@/sdk";
import Image from "next/image";
interface Props {
className?: string;
title?: string;
skus?: string[];
columns?: number;
aboveFold?: boolean;
}
export default async function ProductShowcase({
className, title, skus = [], columns = 3, aboveFold,
}: Props) {
const sdk = await getSdk();
const { products } = await sdk.unified.getProducts({ skus });
if (!products?.length) return null;
return (
<section className={className}>
{title && <h2>{title}</h2>}
<div style={{ display: "grid", gridTemplateColumns: `repeat(${columns}, 1fr)`, gap: "1.5rem" }}>
{products.map((p) => (
<article key={p.id}>
{p.primaryImage && (
<Image src={p.primaryImage.url} alt={p.name} width={300} height={300} priority={aboveFold} />
)}
<h3>{p.name}</h3>
<p>{p.price?.regular?.amount} {p.price?.regular?.currency}</p>
</article>
))}
</div>
</section>
);
}

The AI prompt teaches Claude/Gemma:

  • Use async server components by default — they ship zero client JS.
  • Use inline styles, not Tailwind. Tailwind classes won’t resolve outside the storefront build.
  • The allowed imports are: react, next/image, next/link, @/sdk.
  • Every prop in the Props interface must have a matching schema field (and vice versa), excluding the reserved props.

Local setup

  1. Set your AI key (optional, recommended). Without it, the CMS falls back to Workers AI (Gemma 4) which works but generates lower-quality code:

    apps/cms/.dev.vars
    ANTHROPIC_API_KEY=sk-ant-...
  2. For Figma mode, also add:

    Terminal window
    FIGMA_ACCESS_TOKEN=figd_...

    (Personal access token from Figma account settings.)

  3. Set the CMS public base URL (otherwise wrangler dev rewrites localhost to the production hostname):

    Terminal window
    CMS_PUBLIC_BASE_URL=http://localhost:8787
    AUTH_JWT_SECRET=local-dev-secret-change-me-in-prod
  4. Run the three services:

    Terminal window
    # CMS
    cd apps/cms && yarn dev
    # Storefront middleware
    cd apps/storefront-middleware && yarn dev
    # Storefront
    cd apps/storefront-unified-nextjs && yarn dev
  5. Open the CMS at http://localhost:8787, log in, navigate to Component Builder, and ask for a component. Save it, add it to a page, open the storefront — it renders with real SDK data.

Deploying to production

  1. Run the migration against your D1 instance:

    Terminal window
    cd apps/cms
    npx wrangler d1 execute alokai-cms-db --remote \
    --file=src/db/migrations/023_component_source.sql
  2. Set the required secrets:

    Terminal window
    npx wrangler secret put AUTH_JWT_SECRET # used for HMAC-signed URLs
    npx wrangler secret put ANTHROPIC_API_KEY # optional — falls back to Workers AI
    npx wrangler secret put FIGMA_ACCESS_TOKEN # optional — only for Figma mode

    Add CMS_PUBLIC_BASE_URL as a var in wrangler.jsonc if your CMS runs on a different origin than the one wrangler autodetects.

  3. Deploy: yarn deploy.

  4. Deploy the storefront changes: the storefront repo needs the sf-modules/alokai-cms/runtime/ folder and the updated RenderCmsContent + LivePreviewWrapper + connect-cms-page.

File reference

CMS

PathPurpose
apps/cms/migrations/023_component_source.sqlTable definition
apps/cms/src/server/routes/ai.tsgenerate-component, generate-from-figma
apps/cms/src/server/routes/component-source.tsCRUD, save-with-model, preview-map
apps/cms/src/server/routes/delivery.tscomponentMap injection + signed compiled-module endpoint
apps/cms/src/server/middleware/delivery-auth.tsSigned-URL verification
apps/cms/src/client/lib/compiler.tsesbuild-wasm + externals plugin
apps/cms/src/client/lib/schema-reconciler.tsAuto-fill missing schema fields from Props
apps/cms/src/client/pages/component-builder/ComponentBuilder.tsxPage shell, Zen mode, three-panel layout
apps/cms/src/client/pages/component-builder/ChatPanel.tsxChat input + image attach + paste
apps/cms/src/client/pages/component-builder/CodePanel.tsxMonaco editor + fullscreen
apps/cms/src/client/pages/component-builder/ComponentPreview.tsxClient-side eval + sample-data preview
apps/cms/src/client/pages/component-builder/ComponentLibrary.tsxRemote Components list + search
apps/cms/src/client/pages/component-builder/useWhimsicalStatus.tsRotating status phrases for Zen
apps/cms/src/client/hooks/useAiStream.tsSSE streaming + fence extraction
apps/cms/src/client/components/PreviewFrame.tsxcomponentMap forwarding via postMessage

Storefront

PathPurpose
sf-modules/alokai-cms/runtime/server-externals.tsInjected modules for dynamic components
sf-modules/alokai-cms/runtime/module-resolver.tsFetch + eval + cache
sf-modules/alokai-cms/components/render-cms-content.tsxDynamic fallback branch
sf-modules/alokai-cms/components/live-preview-wrapper.tsxReads componentMap from postMessage
sf-modules/alokai-cms/components/connect-cms-page.tsxPasses componentMap to RenderCmsContent

Known limitations & future work

  • WASM cold start. First compile pays ~100-200ms to init esbuild-wasm. Cached after.
  • No server-side compile fallback. Save flow depends on the browser. A future POST /api/component-source/:id/compile route could run esbuild-wasm in the CMS Worker for CI / scripted imports.
  • Tailwind classes don’t work in Remote Components. The prompt steers the AI toward inline styles. A future enhancement could ship the storefront’s compiled utility CSS so Tailwind works.
  • Cross-component composition. A Remote Component can’t import another Remote Component today — only statics and externals. Flat composition via CMS components slots (the components field type) works fine.
  • Reasoning models leak tokens. Some Workers AI models (Gemma 4) stream their chain-of-thought under delta.reasoning which we deliberately discard — only the final answer is surfaced.