Free SKILL.md scraped from GitHub. Clone the repo or copy the file directly into your Claude Code skills directory.
npx versuz@latest install webiny-website-buildergit clone https://github.com/webiny/webiny-js.gitcp webiny-js/skills/user-skills/website-builder/SKILL.md ~/.claude/skills/webiny-website-builder/SKILL.md---
name: webiny-website-builder
context: webiny-extensions
description: >
Building Website Builder editor components, theming, and CMS integration using
@webiny/website-builder-nextjs. Use this skill when the developer wants to create editor
components for the Website Builder, register components with createComponent, define
configurable inputs (text, number, boolean, color, select, file/image, slot, lexical, object,
tags), type the component's props correctly (especially file/image inputs, which are objects
with { src, width, height, ... } and NOT plain strings, and lexical inputs which are
{ html, state } objects), set up component groups, customize the theme (CSS variables,
createTheme, Tailwind bridge, fonts), build Server Components that fetch CMS data, or
understand the WB architecture (Admin iframe + Next.js). Also use for anything related to
the Website Builder starter kit.
---
# Website Builder
## TL;DR
The Webiny Website Builder uses a unique architecture: the Admin editor loads your Next.js app inside an iframe. All component code and styles live in your Next.js project -- Webiny only stores the page structure (which components and what input values). You build editor components with `@webiny/website-builder-nextjs`, register them via `createComponent()`, define configurable inputs, and manage theming through CSS custom properties and `createTheme()`.
## Architecture
```
+----------------------------------------------------------+
| Webiny Admin |
| +----------------------------------------------------+ |
| | Website Builder Editor | |
| | | |
| | sidebar +------------------------------+ | |
| | (inputs) | your Next.js app (iframe) | | |
| | | real components | | |
| | | real styles | | |
| | +------------------------------+ | |
| +----------------------------------------------------+ |
+----------------------------------------------------------+
postMessage (SDK)
+----------------------------------------------------------+
| Your Next.js App (running separately) |
| @webiny/website-builder-nextjs SDK installed |
+----------------------------------------------------------+
```
Key implications:
- **No style clashes** -- your components, your styles, full ownership
- **Genuine WYSIWYG** -- editors see your real app, not a simulation
- **Framework-owned code** -- all React components live in your Next.js repo
## Setup
### Starter Kit
```bash
git clone https://github.com/webiny/website-builder-nextjs.git my-website
cd my-website
npm install
```
Ensure `@webiny/website-builder-nextjs` and `@webiny/sdk` versions in `package.json` match your Webiny version (`yarn webiny --version` in your Webiny project).
### Environment Variables
```dotenv
# .env
NEXT_PUBLIC_WEBSITE_BUILDER_API_KEY=your_wb_api_key
NEXT_PUBLIC_WEBSITE_BUILDER_API_HOST=https://your-cloudfront-url.cloudfront.net
NEXT_PUBLIC_WEBSITE_BUILDER_ADMIN_HOST=http://localhost:3001
NEXT_PUBLIC_WEBSITE_BUILDER_API_TENANT=root
```
## Editor Components
An editor component has two parts:
1. **React component** -- renders the UI, receives configured values via `inputs` prop
2. **Manifest** -- metadata (name, label, group, inputs) that tells the editor about the component
### Creating a Component
```tsx
// src/editorComponents/Banner.tsx
import React from "react";
import { ComponentProps } from "@webiny/website-builder-nextjs";
interface BannerInputs {
headline: string;
ctaLabel: string;
ctaUrl: string;
}
export function Banner({ inputs: { headline, ctaLabel, ctaUrl } }: ComponentProps<BannerInputs>) {
return (
<div className="bg-primary py-12 px-6 text-center text-white">
<h2 className="text-3xl font-bold mb-4">{headline}</h2>
{ctaLabel && ctaUrl && (
<a
href={ctaUrl}
className="inline-block bg-white text-primary font-semibold px-6 py-3 rounded-md"
>
{ctaLabel}
</a>
)}
</div>
);
}
```
### Registering Components
The `editorComponents` array must be in a `"use client"` file:
```tsx
// src/editorComponents/index.tsx
"use client";
import { createComponent, createTextInput } from "@webiny/website-builder-nextjs";
import { Banner } from "./Banner";
export const editorComponents = [
createComponent(Banner, {
name: "Custom/Banner",
label: "Banner",
group: "custom",
inputs: [
createTextInput({
name: "headline",
label: "Headline",
description: "The main headline text.",
defaultValue: "Ready to get started?"
}),
createTextInput({
name: "ctaLabel",
label: "Button Label",
defaultValue: "Get started"
}),
createTextInput({
name: "ctaUrl",
label: "Button URL",
defaultValue: "/"
})
]
})
];
```
**Important:** The `"use client"` directive is required because component registration communicates with the editor via the browser. However, components imported here can still be Server Components if they don't have their own `"use client"` directive.
### Component Name Convention
Use a namespaced string: `"YourNamespace/ComponentName"`. Component names are stored in page documents -- treat them as **stable identifiers**; renaming breaks existing pages.
## Input Types
| Factory Function | Use Case |
| --------------------- | ----------------------------------- |
| `createTextInput` | Single-line text, URLs, labels |
| `createLongTextInput` | Multi-line text |
| `createNumberInput` | Numeric values |
| `createBooleanInput` | Toggle / checkbox |
| `createColorInput` | Color picker |
| `createDateInput` | Date / date-time picker |
| `createSelectInput` | Dropdown with predefined options |
| `createRadioInput` | Radio button group |
| `createTagsInput` | List of tags |
| `createObjectInput` | Nested object (group of sub-inputs) |
| `createLexicalInput` | Rich text (Lexical editor) |
| `createFileInput` | File / media picker |
| `createSlotInput` | Slot for nesting other components |
Each factory accepts: `name`, `label`, `description`, `defaultValue`, and type-specific options.
### TypeScript prop types for each input
The input factories above define what the editor sidebar shows. Separately, each input
produces a value at runtime that's passed into your React component via `props.inputs`.
**The runtime shape is not always a primitive** — the most important case is `file` /
image inputs, which are objects, not strings. Typing them as `string` compiles but
breaks as soon as you try to read `.src`, `.width`, etc.
Use this table as the source of truth when you write the `ComponentProps<T>` generic for
a component. These shapes come from the actual SDK usage (see e.g.
`@webiny/website-builder-nextjs/editorComponents/Image.d.ts` in the project's
`node_modules` for the canonical file-input shape).
| Input factory | Type of `inputs.<name>` in the component |
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `createTextInput` | `string` |
| `createLongTextInput` | `string` |
| `createNumberInput` | `number` |
| `createBooleanInput` | `boolean` |
| `createColorInput` | `string` (CSS color value, e.g. `"#4632f5"` or `"var(--wb-theme-color-primary)"`) |
| `createDateInput` | `string` (ISO-8601, e.g. `"2026-04-16T14:06:00.000Z"`) |
| `createSelectInput` | `string` (the `value` of the chosen option) |
| `createRadioInput` | `string` (the `value` of the chosen option) |
| `createTagsInput` | `string[]` |
| `createFileInput` | `{ id: string; name: string; size: number; mimeType: string; src: string; width: number; height: number }` — **object, NOT a string.** Use `.src` for the URL. |
| `createLexicalInput` | `{ html?: string; state?: string }` — render with `<div dangerouslySetInnerHTML={{ __html: inputs.<name>.html ?? "" }} />` |
| `createObjectInput` | An object literal matching the shape of its nested `fields` (e.g. `{ street: string; city: string; zip: string }`) |
| `createSlotInput` | `React.ReactNode` (rendered children). With `list: true`, the factory wraps inside the field value — see Grid pattern below. |
#### List inputs (`list: true`)
When an input is declared with `list: true` (either directly, or via a factory like
`createTagsInput` which does it internally), the type in the component becomes an array
of the base type:
- `createFileInput({ list: true })` → `Array<{ id; name; src; … }>`
- `createObjectInput({ name: "rows", list: true, fields: [...] })` → an array of objects
matching the `fields` shape.
- `createSlotInput({ name: "columns", list: true, ... })` → an array of `{ children: React.ReactNode }` (see the Grid component in `@webiny/website-builder-react` for the reference pattern).
#### Worked example: image + rich-text component
```tsx
import React from "react";
import type { ComponentProps } from "@webiny/website-builder-nextjs";
interface FeatureCardInputs {
headline: string;
body: { html?: string };
image: {
id: string;
name: string;
size: number;
mimeType: string;
src: string;
width: number;
height: number;
};
tags: string[];
}
export function FeatureCard({
inputs: { headline, body, image, tags }
}: ComponentProps<FeatureCardInputs>) {
return (
<article>
<h3>{headline}</h3>
{image?.src && (
<img src={image.src} width={image.width} height={image.height} alt={headline} />
)}
{body?.html && <div dangerouslySetInnerHTML={{ __html: body.html }} />}
<ul>
{tags.map(tag => (
<li key={tag}>{tag}</li>
))}
</ul>
</article>
);
}
```
Note how `image` is typed as the full object, not a plain `string` — otherwise the
component would compile but blow up at runtime when it tried to read `image.src`.
## Component Groups
Groups organize the editor's component palette:
```typescript
// src/contentSdk/groups.ts
import { registerComponentGroup, type ComponentManifest } from "@webiny/website-builder-nextjs";
export const registerComponentGroups = () => {
registerComponentGroup({
name: "basic",
label: "Basic",
description: "Components for simple content creation"
});
registerComponentGroup({
name: "custom",
label: "Custom",
description: "Assorted custom components",
filter: (component: ComponentManifest) => !component.group
});
};
```
The `filter` option creates a catch-all group for components without an explicit `group`.
## Theming
The theme system has three files that work together:
### 1. `theme.css` -- CSS Custom Properties
```css
/* src/theme/theme.css */
@import "@webiny/website-builder-nextjs/lexical.css";
:root {
--wb-theme-color-primary: #4632f5;
--wb-theme-color-secondary: #00ccb0;
--wb-theme-color-background: #ffffff;
--wb-theme-color-surface: #f9f9f9;
--wb-theme-color-text-base: #0a0a0a;
--wb-theme-color-text-muted: #6b7280;
--wb-theme-color-border: #e5e7eb;
--wb-theme-font-family: "Inter", sans-serif;
}
.wb-heading-1 {
font-weight: 700;
line-height: 1.2;
font-size: clamp(2rem, 1.5rem + 1.5vw, 3rem);
}
.wb-paragraph-1 {
font-weight: 400;
line-height: 1.6;
font-size: clamp(0.95rem, 0.9rem + 0.25vw, 1rem);
}
```
### 2. `theme.ts` -- Theme Registration
```typescript
// src/theme/theme.ts
import { createTheme } from "@webiny/website-builder-nextjs";
declare const __THEME_CSS__: string;
export const css = __THEME_CSS__;
export const theme = createTheme({
css,
fonts: ["https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap"],
colors: [
{ id: "color-primary", label: "Primary", value: "var(--wb-theme-color-primary)" },
{ id: "color-secondary", label: "Secondary", value: "var(--wb-theme-color-secondary)" },
{ id: "color-background", label: "Background", value: "var(--wb-theme-color-background)" },
{ id: "color-text-base", label: "Text", value: "var(--wb-theme-color-text-base)" }
],
typography: {
headings: [{ id: "heading1", label: "Heading 1", tag: "h1", className: "wb-heading-1" }],
paragraphs: [{ id: "paragraph1", label: "Paragraph 1", tag: "p", className: "wb-paragraph-1" }],
quotes: [{ id: "quote", label: "Quote", tag: "blockquote", className: "wb-blockquote-1" }],
lists: [{ id: "list1", label: "List 1", tag: "ul", className: "wb-unordered-list-1" }]
}
});
```
- `colors` populates the editor's color picker
- `typography` populates the editor's typography toolbar
- `fonts` injects fonts into the editor iframe
### 3. `tailwind.css` -- Tailwind Bridge
```css
/* src/theme/tailwind.css */
@import "tailwindcss";
@theme inline {
--font-sans: InterVariable, sans-serif;
--color-primary: var(--wb-theme-color-primary);
--color-secondary: var(--wb-theme-color-secondary);
--color-text-base: var(--wb-theme-color-text-base);
}
```
This bridges WB CSS variables to Tailwind tokens, enabling `bg-primary`, `text-primary`, etc. in your components.
### Changing Fonts (4 Files)
When switching fonts, update all four places:
| File | What to Update |
| ------------------------ | ------------------------------------------------------------------------- |
| `src/app/layout.tsx` | Font import and config (e.g., `import { Geist } from "next/font/google"`) |
| `src/theme/tailwind.css` | `--font-sans` token |
| `src/theme/theme.css` | `--wb-theme-font-family` variable |
| `src/theme/theme.ts` | `fonts` array URL (must include same weight range as layout.tsx) |
## Server Components Fetching CMS Data
Build editor components that fetch data from the Headless CMS at render time:
```tsx
// src/editorComponents/ProductListing.tsx
import React from "react";
import { ComponentProps } from "@webiny/website-builder-nextjs";
import { sdk } from "@/lib/webiny";
import type { Product } from "@/lib/types";
import type { CmsEntryData } from "@webiny/sdk";
interface ProductListingInputs {
heading: string;
limit: string;
}
export async function ProductListing({
inputs: { heading, limit }
}: ComponentProps<ProductListingInputs>) {
const parsedLimit = parseInt(limit, 10) || 6;
const result = await sdk.cms.listEntries<Product>({
modelId: "product",
limit: parsedLimit,
sort: ["values.name_ASC"]
});
if (!result.isOk()) {
return <div className="text-red-600">Failed to load products: {result.error.message}</div>;
}
const products: CmsEntryData<Product>[] = result.value.data;
return (
<section className="py-12 px-6">
{heading && <h2 className="text-3xl font-bold text-center mb-8">{heading}</h2>}
<ul className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3 max-w-5xl mx-auto">
{products.map(product => (
<li key={product.id} className="border rounded-lg p-6">
<h3 className="text-xl font-semibold">{product.values.name}</h3>
<p className="text-lg font-bold mt-2">${product.values.price.toFixed(2)}</p>
</li>
))}
</ul>
</section>
);
}
```
Register it (async Server Components work even though `index.tsx` is `"use client"`):
```tsx
createComponent(ProductListing, {
name: "Custom/ProductListing",
label: "Product Listing",
inputs: [
createTextInput({
name: "heading",
label: "Section Heading",
defaultValue: "Our Products"
}),
createTextInput({ name: "limit", label: "Number of products", defaultValue: "6" })
]
});
```
To use the Headless CMS SDK, initialize it in `src/lib/webiny.ts` with a **Read API** key (see the `webiny-sdk` skill).
## Data Flow
```
Editor -> saves page document to Webiny API
(document: component name + input values)
Next.js request/build
-> contentSdk.getPage("/slug") -> returns page document
-> DocumentRenderer matches component name to React component
-> Component renders (Server Component may fetch CMS data)
```
## Quick Reference
```
SDK package: @webiny/website-builder-nextjs
Component type: import { ComponentProps } from "@webiny/website-builder-nextjs";
Registration: createComponent(ReactComponent, { name, label, inputs })
Input factories: createTextInput, createNumberInput, createBooleanInput, etc.
Theme: createTheme({ css, fonts, colors, typography })
Groups: registerComponentGroup({ name, label, description })
```
## Related Skills
- `webiny-sdk` -- Using the Headless CMS SDK inside Website Builder components
- `webiny-project-structure` -- Webiny project setup and extension registration