BTST

UI Builder Plugin

Visual drag-and-drop page builder with component registry, variables, and public page rendering

UI Builder Plugin Demo

The UI Builder plugin provides a visual drag-and-drop page creation interface where administrators can create pages using pre-defined components. Pages are stored as JSON layers in the CMS and can be rendered on public routes.

For comprehensive documentation including interactive demos, block templates, and advanced customization options, visit uibuilder.app.

Key Features:

  • Visual Page Builder - Drag-and-drop interface for creating pages with components
  • Component Registry - Pre-built shadcn/ui components + custom component support
  • Variables - Define page variables for dynamic content
  • CMS Integration - Leverages the CMS plugin for data persistence
  • Public Page Rendering - Render pages by slug with the PageRenderer component
  • SSR Authorization - Lifecycle hooks for protecting admin pages

Installation

Ensure you followed the general framework installation guide first. The UI Builder requires the CMS plugin to be configured.

1. Add Content Type to CMS Backend

The UI Builder stores pages as CMS content items. Add the pre-configured content type to your CMS:

lib/stack.ts
import { stack } from "@btst/stack"
import { cmsBackendPlugin } from "@btst/stack/plugins/cms/api"
import { UI_BUILDER_CONTENT_TYPE } from "@btst/stack/plugins/ui-builder"

const { handler, dbSchema } = stack({
  basePath: "/api/data",
  plugins: {
    cms: cmsBackendPlugin({
      contentTypes: [
        UI_BUILDER_CONTENT_TYPE,
        // ... other content types
      ],
      hooks: {
        // Optional: Add authorization for CMS operations
        onBeforeListContent: async (typeSlug, ctx) => {
          if (typeSlug === "ui-builder-page") {
            const session = await getSession(ctx.headers)
            return session?.user?.isAdmin === true
          }
          return true
        },
      },
    })
  },
  adapter: (db) => createMemoryAdapter(db)({})
})

export { handler, dbSchema }

2. Add Plugin to Client

Register the UI Builder client plugin:

lib/stack-client.tsx
import { createStackClient } from "@btst/stack/client"
import { cmsClientPlugin } from "@btst/stack/plugins/cms/client"
import { uiBuilderClientPlugin, defaultComponentRegistry } from "@btst/stack/plugins/ui-builder/client"
import { QueryClient } from "@tanstack/react-query"
import { redirect } from "next/navigation"

const getBaseURL = () => 
  process.env.BASE_URL || "http://localhost:3000"

export const getStackClient = (queryClient: QueryClient, options?: { headers?: Headers }) => {
  const baseURL = getBaseURL()
  return createStackClient({
    plugins: {
      // CMS plugin is required
      "cms": cmsClientPlugin({
        apiBaseURL: baseURL,
        apiBasePath: "/api/data",
        siteBaseURL: baseURL,
        siteBasePath: "/pages",
        queryClient,
        headers: options?.headers,
      }),
      // UI Builder plugin
      "ui-builder": uiBuilderClientPlugin({
        apiBaseURL: baseURL,
        apiBasePath: "/api/data",
        siteBaseURL: baseURL,
        siteBasePath: "/pages",
        queryClient,
        componentRegistry: defaultComponentRegistry,
        headers: options?.headers,
        hooks: {
          // SSR authorization hooks
          beforeLoadPageList: async (context) => {
            const session = await getSession(context.headers)
            return session?.user?.isAdmin === true
          },
          beforeLoadPageBuilder: async (pageId, context) => {
            const session = await getSession(context.headers)
            return session?.user?.isAdmin === true
          },
          onLoadError: (error, context) => {
            redirect("/auth/sign-in")
          },
        },
      })
    }
  })
}

3. Configure Provider Overrides

Add UI Builder overrides to your layout:

app/pages/layout.tsx
import type { UIBuilderPluginOverrides } from "@btst/stack/plugins/ui-builder/client"
import type { CMSPluginOverrides } from "@btst/stack/plugins/cms/client"

type PluginOverrides = {
  "cms": CMSPluginOverrides,
  "ui-builder": UIBuilderPluginOverrides,
}

<StackProvider<PluginOverrides>
  basePath="/pages"
  overrides={{
    "cms": {
      apiBaseURL: baseURL,
      apiBasePath: "/api/data",
      navigate: (path) => router.push(path),
      refresh: () => router.refresh(),
      Link: ({ href, ...props }) => <Link href={href || "#"} {...props} />,
    },
    "ui-builder": {
      apiBaseURL: baseURL,
      apiBasePath: "/api/data",
      navigate: (path) => router.push(path),
      refresh: () => router.refresh(),
      Link: ({ href, ...props }) => <Link href={href || "#"} {...props} />,
      componentRegistry: defaultComponentRegistry,
    }
  }}
>
  {children}
</StackProvider>

4. Install Required Dependencies

The UI Builder uses the Tailwind Typography plugin for the Markdown component. Install it as a dependency:

npm install @tailwindcss/typography
# or
pnpm add @tailwindcss/typography

5. Import CSS

This step is critical for the UI Builder to work correctly.

The UI Builder uses a large number of Tailwind utility classes for dynamic component styling. Add the CSS import to ensure Tailwind v4 scans all required classes:

app/globals.css
@import "@btst/stack/plugins/ui-builder/css";

Without this CSS import, many styling options in the UI Builder will not work because Tailwind won't include the dynamic classes.

Admin Routes

The UI Builder plugin provides these admin routes:

RouteDescription
/ui-builderList all pages with create, edit, delete actions
/ui-builder/newCreate a new page with the visual builder
/ui-builder/:id/editEdit an existing page

Admin routes are automatically set to noindex for SEO. Don't include them in your public sitemap.

Component Registry

The UI Builder uses a component registry to define available components. A default registry is provided with common components:

Default Components

Primitives:

  • div, span, p, h1-h6 - HTML elements
  • a - Anchor links
  • img - Images
  • iframe - Embedded content

Layout Components:

  • Flexbox - Flexible box layout
  • Grid - CSS Grid layout

Content Components:

  • Markdown - Rich text with Markdown support
  • Icon - Lucide icons
  • CodePanel - Syntax-highlighted code blocks

shadcn/ui Components:

  • Button - Interactive buttons
  • Badge - Status badges
  • Card, CardHeader, CardTitle, CardContent, CardFooter - Card layouts
  • Accordion, AccordionItem, AccordionTrigger, AccordionContent - Collapsible sections
  • Separator - Visual separators
  • Carousel, CarouselItem - Image/content carousels

Custom Component Registry

Extend or replace the default registry with your own components:

import { 
  createComponentRegistry, 
  defaultComponentRegistry,
  primitiveComponentDefinitions,
} from "@btst/stack/plugins/ui-builder/client"
import { z } from "zod"

// Extend the default registry
const customRegistry = createComponentRegistry({
  ...defaultComponentRegistry,
  
  // Add a custom component
  HeroSection: {
    component: HeroSection,
    schema: z.object({
      title: z.string().default("Welcome"),
      subtitle: z.string().optional(),
      backgroundImage: z.string().optional(),
      ctaText: z.string().default("Get Started"),
      ctaLink: z.string().default("#"),
    }),
    from: "@/components/hero-section",
  },
  
  // Add a pricing card
  PricingCard: {
    component: PricingCard,
    schema: z.object({
      plan: z.string(),
      price: z.string(),
      features: z.array(z.string()).default([]),
      highlighted: z.boolean().default(false),
    }),
    from: "@/components/pricing-card",
  },
})

// Use in client plugin
uiBuilderClientPlugin({
  // ...
  componentRegistry: customRegistry,
})

Registry Entry Properties

Each component in the registry has these properties:

PropertyTypeRequiredDescription
componentComponentTypeNoThe React component (optional for primitives)
schemaZodSchemaYesZod schema defining props
fromstringNoImport path for code generation
isFromDefaultExportbooleanNoWhether component is default export
defaultChildrenComponentLayer[] | stringNoDefault children when added
defaultVariableBindingsDefaultVariableBinding[]NoAuto-bind props to variables
fieldOverridesRecord<string, FieldConfigFunction>NoCustom form field renderers
childOfstring[]NoRestrict which parent types can contain this component

Parent-Child Restrictions

Use the childOf property to restrict where a component can be placed:

const registry = createComponentRegistry({
  ...defaultComponentRegistry,
  
  // TabsTrigger can only be added inside TabsList
  TabsTrigger: {
    component: TabsTrigger,
    schema: z.object({
      value: z.string(),
      children: z.any(),
    }),
    childOf: ["TabsList"], // Can only be a child of TabsList
  },
  
  // CarouselItem can only be inside Carousel
  CarouselItem: {
    component: CarouselItem,
    schema: z.object({
      children: z.any(),
    }),
    childOf: ["Carousel"],
  },
})

Minimal Registry

Create a minimal registry with only the components you need:

const minimalRegistry = createComponentRegistry({
  div: primitiveComponentDefinitions.div,
  span: primitiveComponentDefinitions.span,
  Flexbox: complexComponentDefinitions.Flexbox,
  Button: complexComponentDefinitions.Button,
})

Block Registry

Blocks are pre-built component compositions that users can insert as templates. They appear in the "Blocks" tab of the add component popover.

Defining Blocks

Create a block registry with reusable templates:

import type { BlockRegistry, ComponentLayer } from "@workspace/ui/components/ui-builder/types"

const myBlocks: BlockRegistry = {
  "hero-01": {
    name: "hero-01",
    category: "hero",
    description: "Hero section with title, subtitle, and CTA button",
    thumbnail: "/blocks/hero-01.png", // Optional preview image
    template: {
      id: "hero-root",
      type: "Flexbox",
      name: "Hero Section",
      props: {
        className: "min-h-[60vh] items-center justify-center bg-gradient-to-b from-primary/10 to-background",
        direction: "column",
        gap: 4,
      },
      children: [
        {
          id: "hero-title",
          type: "h1",
          name: "Title",
          props: { className: "text-5xl font-bold text-center" },
          children: "Welcome to Our Platform",
        },
        {
          id: "hero-subtitle",
          type: "p",
          name: "Subtitle",
          props: { className: "text-xl text-muted-foreground text-center max-w-2xl" },
          children: "Build amazing experiences with our powerful tools",
        },
        {
          id: "hero-cta",
          type: "Button",
          name: "CTA Button",
          props: { variant: "default", size: "lg" },
          children: [
            { id: "cta-text", type: "span", name: "Button Text", props: {}, children: "Get Started" }
          ],
        },
      ],
    },
  },
  "pricing-01": {
    name: "pricing-01",
    category: "pricing",
    description: "Simple pricing card",
    template: {
      id: "pricing-root",
      type: "Card",
      name: "Pricing Card",
      props: { className: "w-full max-w-sm" },
      children: [
        {
          id: "pricing-header",
          type: "CardHeader",
          name: "Header",
          props: {},
          children: [
            { id: "plan-name", type: "CardTitle", name: "Plan", props: {}, children: [
              { id: "plan-text", type: "span", name: "Plan Text", props: {}, children: "Pro" }
            ]},
            { id: "plan-desc", type: "CardDescription", name: "Description", props: {}, children: [
              { id: "desc-text", type: "span", name: "Description Text", props: {}, children: "For professionals" }
            ]},
          ],
        },
        {
          id: "pricing-content",
          type: "CardContent",
          name: "Content",
          props: {},
          children: [
            { id: "price", type: "p", name: "Price", props: { className: "text-4xl font-bold" }, children: "$29/mo" },
          ],
        },
      ],
    },
  },
}

Using Blocks in UIBuilder

Pass the block registry to the UIBuilder component:

import UIBuilder from "@workspace/ui/components/ui-builder"
import { defaultComponentRegistry } from "@btst/stack/plugins/ui-builder/client"

function PageEditor() {
  return (
    <UIBuilder
      componentRegistry={defaultComponentRegistry}
      blocks={myBlocks}
      initialLayers={[]}
      onChange={(layers) => console.log("Layers changed:", layers)}
    />
  )
}

BlockDefinition Properties

PropertyTypeRequiredDescription
namestringYesUnique block identifier, e.g., "hero-01"
categorystringYesCategory for grouping, e.g., "hero", "pricing", "footer"
descriptionstringNoHuman-readable description shown in UI
templateComponentLayerYesThe component tree to insert
thumbnailstringNoPreview image URL
requiredComponentsstring[]NoList of required component types

Blocks appear in a separate "Blocks" tab in the add component popover, organized by category. Users can insert entire pre-built sections with a single click.

Public Page Rendering

The UI Builder provides multiple ways to render pages on public routes, depending on your needs:

ComponentUse CaseServer Component Compatible
PageRendererClient-side rendering with built-in data fetchingNo (requires "use client")
LayerRendererRender pre-fetched layer data on the clientNo (requires "use client")
ServerLayerRendererSSR/RSC rendering of layer dataYes

PageRenderer (Client Component)

The PageRenderer component fetches and renders UI Builder pages by slug:

app/preview/[slug]/page.tsx
"use client"

import { PageRenderer } from "@btst/stack/plugins/ui-builder/client"

export default function PreviewPage({ params }: { params: { slug: string } }) {
  return (
    <PageRenderer
      slug={params.slug}
      className="min-h-screen"
    />
  )
}

PageRenderer Props

PropTypeDescription
slugstringPage slug to fetch and render
componentRegistryComponentRegistryCustom registry (defaults to default)
variableValuesRecord<string, any>Runtime variable values
LoadingComponentComponentTypeCustom loading state
ErrorComponentComponentType<{ error }>Custom error state
NotFoundComponentComponentTypeCustom 404 state
classNamestringAdditional CSS classes

LayerRenderer (Client Component)

The LayerRenderer component renders pre-fetched ComponentLayer data. Use this when you've already fetched the page data and want more control:

app/pages/[slug]/page.tsx
"use client"

import { LayerRenderer } from "@workspace/ui/components/ui-builder"
import { defaultComponentRegistry } from "@btst/stack/plugins/ui-builder/client"

export default function DynamicPage({ page, variables }: { 
  page: ComponentLayer, 
  variables: Variable[] 
}) {
  return (
    <LayerRenderer
      page={page}
      componentRegistry={defaultComponentRegistry}
      variables={variables}
      variableValues={{
        userName: "John Doe",
      }}
      className="min-h-screen"
    />
  )
}

LayerRenderer Props

PropTypeRequiredDescription
pageComponentLayerYesThe root ComponentLayer to render
componentRegistryComponentRegistryYesComponent definitions registry
variablesVariable[]NoVariable definitions from the page
variableValuesRecord<string, PropValue>NoRuntime values for variables
editorConfigEditorConfigNoEditor configuration (for admin use)
classNamestringNoAdditional CSS classes

ServerLayerRenderer (Server Component)

The ServerLayerRenderer is an SSR-friendly renderer that works in React Server Components (RSC), Static Site Generation (SSG), and Server-Side Rendering (SSR):

app/pages/[slug]/page.tsx
// No "use client" needed - this is a Server Component
import { ServerLayerRenderer } from "@workspace/ui/components/ui-builder"
import { defaultComponentRegistry } from "@btst/stack/plugins/ui-builder/client"

export default async function StaticPage({ params }: { params: { slug: string } }) {
  // Fetch page data on the server
  const page = await fetchPageFromDatabase(params.slug)
  
  if (!page) {
    return <NotFound />
  }
  
  const layers = JSON.parse(page.parsedData.layers)
  const variables = JSON.parse(page.parsedData.variables)
  
  return (
    <ServerLayerRenderer
      page={layers[0]}
      componentRegistry={defaultComponentRegistry}
      variables={variables}
      variableValues={{
        // Pass any runtime values
        currentYear: new Date().getFullYear(),
      }}
      className="min-h-screen"
    />
  )
}

ServerLayerRenderer Props

PropTypeRequiredDescription
pageComponentLayerYesThe root ComponentLayer to render
componentRegistryComponentRegistryYesComponent definitions registry
variablesVariable[]NoVariable definitions from the page
variableValuesRecord<string, PropValue>NoRuntime values for variables
classNamestringNoAdditional CSS classes

When to use ServerLayerRenderer:

  • Static pages that can be rendered at build time (SSG)
  • SEO-critical pages that need server-side rendering
  • Pages where you want to avoid client-side JavaScript for the initial render
  • When using React Server Components in Next.js App Router

When to use LayerRenderer:

  • Pages with interactive editor features
  • When you need client-side state management
  • Pages that require client-side data fetching

With Variable Values

Pass runtime values to page variables:

import { PageRenderer } from "@btst/stack/plugins/ui-builder/client"

export default function UserPage({ params }: { params: { slug: string } }) {
  const user = useCurrentUser()
  
  return (
    <PageRenderer
      slug={params.slug}
      variableValues={{
        userName: user?.name ?? "Guest",
        userEmail: user?.email,
        isLoggedIn: !!user,
      }}
    />
  )
}

SSR with Suspense

For server-side rendering with streaming:

import { Suspense } from "react"
import { SuspensePageRenderer } from "@btst/stack/plugins/ui-builder/client"

export default function Page({ params }: { params: { slug: string } }) {
  return (
    <Suspense fallback={<PageSkeleton />}>
      <SuspensePageRenderer slug={params.slug} />
    </Suspense>
  )
}

## Client Hooks

Access UI Builder data with React Query hooks:

### Available Hooks

| Hook | Description | Returns |
|------|-------------|---------|
| `useUIBuilderPages()` | List all pages | `{ pages, total, isLoading, loadMore, hasMore }` |
| `useSuspenseUIBuilderPages()` | Suspense variant | `{ pages, total, loadMore, hasMore }` |
| `useUIBuilderPage(id)` | Get page by ID | `{ page, isLoading, error }` |
| `useUIBuilderPageBySlug(slug)` | Get page by slug | `{ page, layers, variables, isLoading }` |
| `useCreateUIBuilderPage()` | Create mutation | React Query mutation |
| `useUpdateUIBuilderPage()` | Update mutation | React Query mutation |
| `useDeleteUIBuilderPage()` | Delete mutation | React Query mutation |

### Usage Examples

```tsx
import { 
  useUIBuilderPageBySlug,
  useCreateUIBuilderPage,
  useUIBuilderPages 
} from "@btst/stack/plugins/ui-builder/client"

// Public: Fetch page by slug
function DynamicPage({ slug }: { slug: string }) {
  const { page, layers, variables, isLoading } = useUIBuilderPageBySlug(slug)
  
  if (isLoading || !page) return <Loading />
  
  return (
    <LayerRenderer
      page={layers[0]}
      componentRegistry={defaultComponentRegistry}
      variables={variables}
    />
  )
}

// Admin: Create new page
function CreatePage() {
  const createPage = useCreateUIBuilderPage()
  
  const handleCreate = async () => {
    await createPage.mutateAsync({
      slug: "my-new-page",
      layers: [
        {
          id: "root",
          type: "div",
          name: "Page Root",
          props: { className: "min-h-screen p-8" },
          children: [],
        }
      ],
      status: "draft",
    })
  }
  
  return <Button onClick={handleCreate}>Create Page</Button>
}

SSR Authorization Hooks

Use hooks in the client plugin config for async authorization during SSR:

lib/stack-client.tsx
import { redirect } from "next/navigation"

"ui-builder": uiBuilderClientPlugin({
  // ... config
  hooks: {
    beforeLoadPageList: async (context) => {
      // context.headers contains request headers
      const session = await getSession(context.headers)
      return session?.user?.isAdmin === true
    },
    beforeLoadPageBuilder: async (pageId, context) => {
      // pageId is undefined for new pages
      const session = await getSession(context.headers)
      return session?.user?.isAdmin === true
    },
    afterLoadPageList: async (context) => {
      console.log("Page list loaded")
    },
    afterLoadPageBuilder: async (pageId, context) => {
      console.log("Page builder loaded for:", pageId)
    },
    onLoadError: (error, context) => {
      // Redirect on authorization failure
      redirect("/auth/sign-in")
    },
  },
})

UIBuilderClientHooks

HookParametersReturnDescription
beforeLoadPageListcontextboolean | Promise<boolean>SSR auth for page list
afterLoadPageListcontextvoidPost-load lifecycle
beforeLoadPageBuilderpageId, contextboolean | Promise<boolean>SSR auth for builder
afterLoadPageBuilderpageId, contextvoidPost-load lifecycle
onLoadErrorerror, contextvoidHandle auth failures

LoaderContext

All hooks receive a context object:

PropertyTypeDescription
pathstringCurrent route path
paramsRecord<string, string>Route parameters
isSSRbooleanWhether running on server
apiBaseURLstringAPI base URL
apiBasePathstringAPI path prefix
headersHeaders | undefinedRequest headers

Page Data Structure

UI Builder pages are stored with this structure:

interface UIBuilderPage {
  id: string
  slug: string              // URL-friendly identifier
  parsedData: {
    layers: string          // JSON-serialized ComponentLayer[]
    variables: string       // JSON-serialized Variable[]
    status: "published" | "draft" | "archived"
  }
  createdAt: string
  updatedAt: string
}

interface ComponentLayer {
  id: string
  type: string              // Component type from registry
  name?: string             // Display name in layers panel
  props: Record<string, any>
  children: ComponentLayer[] | string | VariableReference
}

interface Variable {
  id: string
  name: string
  type: "string" | "number" | "boolean" | "function"
  defaultValue: any
}

// Variable reference for dynamic content
interface VariableReference {
  __variableRef: string     // Variable ID
}

Variable References

You can bind component children to variables for dynamic content:

const layer: ComponentLayer = {
  id: "greeting",
  type: "h1",
  name: "Greeting",
  props: { className: "text-2xl font-bold" },
  // Children bound to a variable
  children: { __variableRef: "userName" }
}

const variables: Variable[] = [
  { id: "userName", name: "User Name", type: "string", defaultValue: "Guest" }
]

When rendering, pass runtime values to override defaults:

<PageRenderer
  slug="welcome"
  variableValues={{ userName: "John Doe" }}
/>

API Reference

Backend (@btst/stack/plugins/ui-builder)

UI_BUILDER_CONTENT_TYPE

Pre-configured content type for UI Builder pages. Add to your CMS config:

import { UI_BUILDER_CONTENT_TYPE } from "@btst/stack/plugins/ui-builder"

cms: cmsBackendPlugin({
  contentTypes: [UI_BUILDER_CONTENT_TYPE]
})

Client (@btst/stack/plugins/ui-builder/client)

uiBuilderClientPlugin

Creates the client plugin with routes and SSR loaders.

UIBuilderPluginOverrides

PropertyTypeRequiredDescription
apiBaseURLstringYesBase URL for API requests
apiBasePathstringYesAPI path prefix
navigate(path: string) => voidYesNavigation function
LinkComponentTypeNoLink component
refresh() => voidNoRefresh function
componentRegistryComponentRegistryNoCustom component registry
showAttributionbooleanNoShow BTST attribution

defaultComponentRegistry

The default component registry with primitives and shadcn/ui components.

createComponentRegistry

Helper function to create a type-safe component registry:

const registry = createComponentRegistry({
  ...defaultComponentRegistry,
  CustomComponent: { component: CustomComponent, schema: customSchema },
})

UIBuilder

The main visual editor component. Import from @workspace/ui/components/ui-builder:

import UIBuilder from "@workspace/ui/components/ui-builder"

<UIBuilder
  componentRegistry={myRegistry}
  blocks={myBlocks}
  functionRegistry={myFunctionRegistry}
  initialLayers={initialLayers}
  initialVariables={initialVariables}
  onChange={(layers) => saveLayers(layers)}
  onVariablesChange={(variables) => saveVariables(variables)}
  allowPagesCreation={true}
  allowPagesDeletion={true}
  allowVariableEditing={true}
  persistLayerStore={false}
  showExport={true}
  navLeftChildren={<Logo />}
  navRightChildren={<SaveButton />}
/>
UIBuilder Props
PropTypeRequiredDefaultDescription
componentRegistryComponentRegistryYes-Registry of available components
blocksBlockRegistryNo-Pre-built block templates for the Blocks tab
functionRegistryFunctionRegistryNo-Bindable event handlers (onClick, onSubmit, etc.)
initialLayersComponentLayer[]No-Initial layer tree to load
initialVariablesVariable[]No-Initial variables to load
onChange(layers: ComponentLayer[]) => voidNo-Callback when layers change
onVariablesChange(variables: Variable[]) => voidNo-Callback when variables change
persistLayerStorebooleanNotruePersist layers to localStorage
allowVariableEditingbooleanNotrueShow variables panel
allowPagesCreationbooleanNotrueAllow creating new pages
allowPagesDeletionbooleanNotrueAllow deleting pages
showExportbooleanNotrueShow export button in navbar
navLeftChildrenReactNodeNo-Content for left side of navbar
navRightChildrenReactNodeNo-Content for right side of navbar
panelConfigPanelConfigNo-Custom panel configuration

PageRenderer

Component to render UI Builder pages on public routes (client component):

<PageRenderer
  slug="homepage"
  componentRegistry={customRegistry}
  variableValues={{ userName: "John" }}
/>

LayerRenderer

Component to render pre-fetched ComponentLayer data (client component):

import { LayerRenderer } from "@workspace/ui/components/ui-builder"

<LayerRenderer
  page={rootLayer}
  componentRegistry={customRegistry}
  variables={variables}
  variableValues={{ userName: "John" }}
/>

ServerLayerRenderer

SSR-friendly renderer for React Server Components:

import { ServerLayerRenderer } from "@workspace/ui/components/ui-builder"

// In a Server Component (no "use client" needed)
<ServerLayerRenderer
  page={rootLayer}
  componentRegistry={customRegistry}
  variables={variables}
  variableValues={{ currentYear: 2024 }}
/>

Types (@workspace/ui/components/ui-builder/types)

BlockDefinition

Pre-built component compositions for templates:

interface BlockDefinition {
  name: string           // Unique block name, e.g., "login-01"
  category: string       // Block category, e.g., "login", "sidebar"
  description?: string   // Human-readable description
  template: ComponentLayer  // ComponentLayer tree to insert
  thumbnail?: string     // Optional preview image URL
  requiredComponents?: string[]  // Required shadcn components
}

BlockRegistry

A record of block name to block definition:

type BlockRegistry = Record<string, BlockDefinition>

FunctionDefinition

Defines a callable function that can be bound to component event handlers via the function registry:

interface FunctionDefinition {
  name: string                // Human-readable name
  schema: ZodTuple | ZodObject | ZodSchema  // Parameter schema
  fn: (...args: any[]) => any // The actual function
  description?: string        // Description shown in UI
  typeSignature?: string      // TS type signature for code generation
}

FunctionRegistry

A record of function ID to function definition:

type FunctionRegistry = Record<string, FunctionDefinition>

Variables with type: "function" reference a key in the FunctionRegistry. At runtime, the function is resolved and bound to the component prop (e.g. onClick, onSubmit).

import { z } from "zod"

const functionRegistry: FunctionRegistry = {
  handleSubmit: {
    name: "Handle Submit",
    description: "Process form submission",
    schema: z.tuple([z.custom<React.FormEvent<HTMLFormElement>>()]),
    fn: (e) => { e.preventDefault(); console.log("submitted") },
    typeSignature: "(e: React.FormEvent<HTMLFormElement>) => void",
  },
}

<UIBuilder
  componentRegistry={myRegistry}
  functionRegistry={functionRegistry}
  // ...
/>

ComponentLayer

The structure representing a component in the UI Builder tree:

interface ComponentLayer {
  id: string
  type: string                          // Component type from registry
  name?: string                         // Display name in layers panel
  props: Record<string, PropValue>
  children: ComponentLayer[] | string | VariableReference
}

Variable

Variable definition for dynamic content:

interface Variable {
  id: string
  name: string
  type: "string" | "number" | "boolean" | "function"
  defaultValue: any
}

VariableReference

Reference to a variable for dynamic binding:

interface VariableReference {
  __variableRef: string  // Variable ID
}