Skip to main content
A portal is the host application that mounts <juo-context-root>, provides services, registers blocks, and decides what to render where. Themes ship one. So can extensions and standalone storefront surfaces. This guide walks through building a portal from scratch, then layering on the editor integration.
Prefer to learn from a working example? The sample-beauty-portal repository is a complete reference portal — registered blocks, services wired up, editor mode enabled — to clone and adapt.

Install

@juo/blocks ships as a single package:
npm install @juo/blocks
vue, react, and preact are optional peer dependencies — install only the one the portal runs on, or none for plain DOM rendering.

Entry points

@juo/blocks exposes a handful of subpath entry points. Import from the ones that match the stack — bundlers tree-shake the rest:
Entry pointPurpose
@juo/blocksCore: block system, signals, contexts, services
@juo/blocks/vueVue 3 renderers and composables
@juo/blocks/reactReact renderers and hooks
@juo/blocks/preactPreact renderers and hooks
@juo/blocks/editorBridge for integrating with the Juo Editor
@juo/blocks/web-components/editorCustom elements with Editor affordances (selection, inline text editing)
@juo/blocks/web-components/runtimeLean custom elements for storefront rendering

Anatomy

A portal does four things:
  1. Loads the web components. Pick the runtime or editor build.
  2. Mounts a <juo-context-root> in the HTML shell.
  3. Provides services on the root.
  4. Registers blocks so the editor and runtime can instantiate them by name.
When the portal runs inside the editor iframe, it also calls setupEditorMode to advertise its blocks and routes. See Editor for what that protocol carries.

Runtime: the bare minimum

<!-- index.html -->
<!doctype html>
<html>
  <body>
    <juo-context-root>
      <juo-page route="/"></juo-page>
    </juo-context-root>

    <script type="module" src="/src/main.ts"></script>
  </body>
</html>
// src/main.ts — runtime build
import "@juo/blocks/web-components/runtime";
import {
  provideContext,
  registerBlock,
  createThemeState,
  ThemeStateContext,
  createSubscriptionService,
  createApiSubscriptionAdapter,
  createCustomerService,
  createApiCustomerAdapter,
  SubscriptionServiceContext,
  CustomerServiceContext,
} from "@juo/blocks";

import { HomePageBlock } from "./blocks/HomePage";
import { ButtonBlock } from "./blocks/Button";

// 1. Register blocks
registerBlock(HomePageBlock);
registerBlock(ButtonBlock);

// 2. Provide services on the root
const root = document.querySelector("juo-context-root") as HTMLElement;

provideContext(
  root,
  SubscriptionServiceContext,
  createSubscriptionService(createApiSubscriptionAdapter({ baseUrl: "/api/v1" })),
);

provideContext(
  root,
  CustomerServiceContext,
  createCustomerService(createApiCustomerAdapter({ baseUrl: "/api/v1" })),
);

// 3. Provide a ThemeState so <juo-page> knows what to render for each route
provideContext(
  root,
  ThemeStateContext,
  createThemeState({
    resolve: async (_surface, path) =>
      path === "/" ? { blocks: [{ name: "HomePage", props: {}, slots: {} }] } : null,
  }),
);
The <juo-page route="/"> element subscribes to ThemeStateContext, resolves the / route, and renders the resulting root block. Nested blocks resolve services from the surrounding context tree.

Setup: providing services and rendering the page

The app shell registers blocks and calls provideContext for each service on the <juo-context-root>. <juo-page> then injects ThemeStateContext to resolve the current route and render the resulting root block. A green juo-context-root containing a gray app shell. Inside the app shell, two rose 'provideContext' rectangles sit in a row — one for ThemeStateContext and one for SubscriptionServiceContext. Below them, a cyan juo-page route='/' frame contains a single rose 'injectContext ThemeStateContext' rectangle. A dashed rose arrow labelled 'resolves' connects the provideContext ThemeStateContext to the injectContext inside juo-page.

Composition: rendering a block from theme state

The resolved root block emits one or more <juo-extension-root> slots; the children placed in each slot are themselves blocks. Each block calls injectContext to pull services out of the surrounding context tree, and the renderer turns its props plus those services into DOM. An amber juo-extension-root name='main' frame containing a purple juo-block SubscriptionList. Inside the block, a rose 'injectContext SubscriptionServiceContext' rectangle sits above a dashed 'renderer output' panel showing a stylized subscription list — a 'Your subscriptions' header followed by three rows, each with a thumbnail, two text lines, a colored 'Active' or 'Paused' status pill, and a 'Manage' button. See Web components for the full list of custom elements, Services for the providers, and Contexts for how the lookup works.

Adding editor support

When the merchant opens the portal inside the editor, the iframe loads the editor build of the web components and the portal needs to advertise its blocks and routes. Detect the editor (e.g. by checking window.parent !== window or a URL flag), load the right web component bundle, and call setupEditorMode:
// src/main.ts — editor-aware
import {
  provideContext,
  registerBlock,
  createThemeState,
  createRouterService,
  ThemeStateContext,
  RouterServiceContext,
} from "@juo/blocks";
import { setupEditorMode } from "@juo/blocks/editor";

const isEditor = window.parent !== window;

if (isEditor) {
  await import("@juo/blocks/web-components/editor");
} else {
  await import("@juo/blocks/web-components/runtime");
}

const root = document.querySelector("juo-context-root") as HTMLElement;

// ... register blocks and provide services ...

const themeState = createThemeState({
  resolve: (surface, path) => editor.requestThemeState(surface, path),
  upsertState: (surface, path, blocks) => editor.upsertThemeState(surface, path, blocks),
});
provideContext(root, ThemeStateContext, themeState);

const routerService = createRouterService();
provideContext(root, RouterServiceContext, routerService);

if (isEditor) {
  const cleanup = await setupEditorMode({ routerService, themeState });
  window.addEventListener("beforeunload", cleanup);
}
setupEditorMode:
  • Serializes every registered block (name, group, schema, initial value, presets, locales) and sends it to the editor.
  • Reports the portal’s routes from routerService.getPages().
  • Subscribes to incoming editor messages (selection, prop updates, theme state changes, translation overrides) and applies them to the local ThemeState.
It returns a cleanup function — call it on teardown.

Preset previews with useBridge

The editor’s block picker shows presets for each block (see Blocks → Presets). When the merchant hovers a preset, the editor sends a SET_BLOCK_PRESET message and the matching block can render a preview without committing. A block opts into preset previews using useBridge from its framework adapter:
<!-- Vue -->
<script setup lang="ts">
import { useBridge } from "@juo/blocks/vue";
import { ref } from "vue";

const previewProps = ref<MyProps | null>(null);

useBridge<typeof MyBlock["presets"]>({
  onPresetChange(preset) {
    previewProps.value = preset?.state.props ?? null;
  },
});
</script>
// React
import { useState } from "react";
import { useBridge } from "@juo/blocks/react";

function MyBlock() {
  const [previewProps, setPreviewProps] = useState<MyProps | null>(null);

  useBridge<typeof MyBlock["presets"]>({
    onPresetChange(preset) {
      setPreviewProps(preset?.state.props ?? null);
    },
  });
}
// Preact
import { useState } from "preact/hooks";
import { useBridge } from "@juo/blocks/preact";

function MyBlock() {
  const [previewProps, setPreviewProps] = useState<MyProps | null>(null);

  useBridge<typeof MyBlock["presets"]>({
    onPresetChange(preset) {
      setPreviewProps(preset?.state.props ?? null);
    },
  });
}
useBridge is a no-op outside the editor — isEditorMode returns false and onPresetChange never fires. You can ship the same block to both modes without branching.

Folder layout

A typical portal package looks like:
src/
  main.ts                  ← entry: register blocks, provide services, setup editor
  blocks/
    HomePage/index.ts      ← defineBlock + renderer
    Button/index.ts
    SubscriptionCard/index.ts
  services/
    router.ts              ← the concrete RouterService
  index.html
Keep defineBlock calls in their own folder per block — each block is self-contained, and registerBlock is the only thing that needs to touch them all.