
Fusion To Publish V2
- 5 installs
- 11 repo stars
- Updated July 30, 2026
- builderio/builder-agent-skills
fusion-to-publish-v2 is the v2 skill that registers React components for Builder.io Publish's visual editor, adding a detect-project script and Gen1/Gen2 SDK detection.
About
The v2 skill for bridging Builder.io Fusion and Publish by registering React components for the visual editor. A developer uses it to scaffold the catch-all route, registry, SDK config, and .builderrules, with an added detect-project.sh script that reports SDK version, Next.js version, and scaffolding status before making changes. It maps TypeScript props to Builder input types and can bulk-register components.
- v2 of registering React components for Builder.io Publish's visual editor
- Adds a detect-project.sh quick-start to read SDK/Next.js version and scaffolding status
- Supports both Gen1 @builder.io/react and Gen2 @builder.io/sdk-react detection
Fusion To Publish V2 by the numbers
- 5 all-time installs (skills.sh)
- Ranked #1,786 of 2,244 Frontend Development skills by installs in the Skillselion catalog
- Data as of Jul 31, 2026 (Skillselion catalog sync)
fusion-to-publish-v2 capabilities & compatibility
- Capabilities
- frontend · api development
- Works with
- vercel
- Use cases
- frontend · web design
What fusion-to-publish-v2 says it does
Bridge Builder.io Fusion (code generation) and Publish (visual CMS) by registering React components for the visual editor.
This outputs the SDK version, Next.js version, app root, registry status, API key status, and catch-all route status.
Check `package.json` for `@builder.io/react` (Gen1) or `@builder.io/sdk-react` (Gen2)
npx skills add https://github.com/builderio/builder-agent-skills --skill fusion-to-publish-v2Add your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 5 |
|---|---|
| repo stars | ★ 11 |
| Last updated | July 30, 2026 |
| Repository | builderio/builder-agent-skills ↗ |
What it does
Register React components into Builder.io Publish using a project-state detection step before scaffolding.
Who is it for?
Setting up Publish integration with an automated project-state detection step first.
Skip if: Skipping the detect-project step and blindly re-scaffolding files that already exist.
When should I use this skill?
The user wants to register a component for Publish, set up Publish integration, or do an f2p setup.
What you get
The project state is detected first, then only missing Publish scaffolding is created and components registered.
- builder-registry.ts
- catch-all route app/[...page]/page.tsx
- components/builder.tsx
By the numbers
- Covers 3 workflows: set up, register, bulk-register
- detect-project.sh reports 6 project-state fields
Files
Fusion to Publish (v2)
Bridge Builder.io Fusion (code generation) and Publish (visual CMS) by registering React components for the visual editor.
What You'll Do
1. Set up Publish integration — scaffold the catch-all route, registry, SDK config, and .builderrules 2. Register a component — read TypeScript props, map to Builder input types, add to registry 3. Bulk-register components — scan a directory and register all components at once
Quick Start: Detect Project State
Before starting any workflow, run this to understand the project's current state:
bash .builder/skills/fusion-to-publish-v2/scripts/detect-project.shThis outputs the SDK version, Next.js version, app root, registry status, API key status, and catch-all route status. Use this to decide which scaffolding steps to skip.
If the script is not available, detect manually: 1. Check package.json for @builder.io/react (Gen1) or @builder.io/sdk-react (Gen2) 2. Check for src/app/ or app/ directory 3. Check if builder-registry.ts exists at the project root 4. Check .env.local for NEXT_PUBLIC_BUILDER_API_KEY
Workflow 1: Set Up Publish Integration
Run this once per project. Each step is idempotent — skip what already exists.
Prerequisites
- A Next.js App Router project (with
app/orsrc/app/) - A Builder.io Publish space with a public API key
Steps
Run the detect script first (see Quick Start above). Then create only what is missing. If a file exists but differs from the expected pattern, show the user and ask how to proceed.
1. Ensure SDK is installed. Check package.json for @builder.io/react. If missing, install it along with @builder.io/dev-tools:
npm install @builder.io/react @builder.io/dev-tools2. Configure the API key. Check .env.local for NEXT_PUBLIC_BUILDER_API_KEY. If not found, check .env. If in .env but not .env.local, warn the user: "Your API key is in .env, which may be committed to version control. Consider moving it to .env.local." If not found anywhere, ask the user for their Builder.io public API key. Write it to .env.local. Ensure .gitignore includes .env*.local. Create .env.example with NEXT_PUBLIC_BUILDER_API_KEY=your-builder-public-api-key.
3. Create `builder-registry.ts` at the project root. See references/scaffolding-templates.md for the exact template. Key points:
- Must have
"use client"directive - Initializes the client-side SDK via
@builder.io/react - Uses a runtime guard for the API key (no
!assertion)
4. Create the catch-all route at app/[...page]/page.tsx (or src/app/[...page]/page.tsx). This is a server component that fetches Builder content. See references/scaffolding-templates.md. Key points:
- Uses
@builder.io/sdk(NOT@builder.io/react) for server-side fetching - Has its own
builder.init()— this is separate from the client-side init in the registry - Uses
[...page](required catch-all), NOT[[...page]](optional), to preserve the user's homepage - Detect the Next.js version from
package.jsonfor the correctparamspattern (Promise in 15+, direct in 14)
Before creating, check for existing catch-all or dynamic routes ([...page], [[...page]], [...slug]). If found, show the user and ask how to proceed.
5. Create `components/builder.tsx` (the RenderBuilderContent wrapper). See references/scaffolding-templates.md. Key points:
- Must have
"use client"directive - Imports
@/builder-registryas a side-effect (triggers all component registrations) - Accepts an optional
modelprop (defaults to"page")
6. Wrap `next.config` with @builder.io/dev-tools. Check if already wrapped by searching for @builder.io/dev-tools or BuilderDevTools. If found, skip. If the file has BuilderDevTools()(BuilderDevTools()( (double wrapping), fix it. Handle .ts, .js, and .mjs variants.
7. Create or update `.builderrules` with component creation conventions. Adapt the template to match existing project conventions (e.g., Tailwind vs CSS Modules, src/ vs root). See references/scaffolding-templates.md for the default template.
After Scaffolding
Tell the user: "Publish integration is set up. Deploy your app to a public URL (Vercel, Netlify, etc.) — Builder's visual editor cannot work with localhost. Then use this skill to register components."
Workflow 2: Register a Component
When the user wants to register a component for Publish's visual editor.
Prerequisites
Verify scaffolding is complete: builder-registry.ts exists, the catch-all route exists, SDK is installed. If anything is missing, offer to run Workflow 1 first.
Steps
1. Read the component file. Find the exported props interface or type. If the type is imported from another file, follow the import to resolve it. Skip React-internal types (ReactNode, CSSProperties, MouseEventHandler, HTMLAttributes, etc.).
2. Map props to Builder inputs. Use the type mapping in references/sdk-reference.md. Key rules:
- Evaluate TypeScript type first, then apply name heuristics only for plain
stringprops - String literal unions (
'a' | 'b') →type: "text"withenumarray (NOTtype: "enum") listinputs MUST havedefaultValue: []objectinputs MUST havedefaultValue: {}children: ReactNode→ skip the prop, addcanHaveChildren: trueto the registration- Generate
friendlyNamefrom PascalCase props (backgroundColor→"Background Color") - Generate
helperTextfor non-obvious inputs - Use
advanced: truefor typically-defaulted props (className,id)
3. Generate the registration. Add a Builder.registerComponent() call to builder-registry.ts using dynamic(() => import(...)) with the correct relative path from the registry file (or use the @/ alias). See references/examples.md for complete examples.
4. Optionally update the showcase page. If app/page.tsx has a "Component Showcase" pattern, add the component with sample data. If the homepage is not a showcase page, skip this step.
5. Log the registration. Append an entry to registration-log.json in this skill's directory (see Registration History below).
6. Guide verification. Tell the user:
- Deploy the updated app
- Open Builder.io → Publish space → Visual Editor
- Check the Insert tab → Custom Components → look for the registered component
- Drag it onto the canvas and verify inputs appear correctly
- If the component doesn't appear: check that
builder-registry.tsis imported incomponents/builder.tsx, the dynamic import path is correct, and the app is deployed
Workflow 3: Bulk Register Components
Scan a directory and register all unregistered components. Use parallel sub-agents to analyze multiple components simultaneously.
Step 1: Scan
Run the scan script to identify what needs registration:
bash .builder/skills/fusion-to-publish-v2/scripts/scan-components.sh components builder-registry.tsIf the script is not available, scan manually: find .tsx/.jsx files, skip *.test.*, *.spec.*, *.stories.*, *.story.*, *.mock.*, *.d.ts, and barrel files.
Step 2: Analyze in parallel (sub-agents)
For each UNREGISTERED component from the scan, spawn a sub-agent to analyze it independently. All sub-agents run in parallel.
Each sub-agent receives:
- The component file path
- The type mapping rules from references/sdk-reference.md
- The examples from references/examples.md
Each sub-agent does: 1. Read the component file 2. Parse the exported props interface (follow imports if needed) 3. Map each prop to a Builder input using the type mapping rules 4. Generate the complete Builder.registerComponent() code block 5. Return the registration code and a summary (component name, input count, any warnings)
Example sub-agent dispatch:
For each unregistered component, spawn a sub-agent:
Sub-agent 1: "Read components/HeroSection/HeroSection.tsx.
Parse its props interface.
Map props to Builder inputs using the type mapping in references/sdk-reference.md.
Generate a Builder.registerComponent() call.
Return the complete registration code block."
Sub-agent 2: "Read components/PricingCard/PricingCard.tsx. [same instructions]"
Sub-agent 3: "Read components/TestimonialGrid/TestimonialGrid.tsx. [same instructions]"
All sub-agents run in parallel.Step 3: Assemble registrations (serial)
After all sub-agents complete, collect their registration code blocks and:
1. Review each registration for correctness (check for warnings from sub-agents) 2. Add all registrations to `builder-registry.ts` — append them sequentially. Add the import dynamic from "next/dynamic" at the top if not already present. 3. Log each registration to registration-log.json
This step must be serial because all registrations write to the same file.
Step 4: Report results
Summarize: how many registered, skipped (already registered), and failed (with reasons from sub-agents).
If a sub-agent fails to analyze a component (e.g., complex types that can't be resolved), report the failure but don't block other registrations.
Registration History
After each successful registration, append an entry to registration-log.json in this skill's directory:
{
"registrations": [
{
"component": "HeroSection",
"file": "components/HeroSection/HeroSection.tsx",
"registeredAt": "2026-03-19T20:30:00Z",
"inputCount": 5,
"notes": "Container with canHaveChildren"
}
]
}Before registering a component, check this log. If a component was previously registered but is no longer in builder-registry.ts, note this for the user — it may have been intentionally removed.
Gotchas
These are the most common failure modes. Check here first when debugging.
1. `list` and `object` inputs require `defaultValue`. Without defaultValue: [] (list) or defaultValue: {} (object), Builder errors when adding the component to a page. Non-obvious and causes "component won't load" debugging.
2. `enum` requires `type: "text"` with an `enum` array. There is no type: "enum" in Builder. Using it causes the input to silently not render.
3. Two separate `builder.init()` calls are correct. The catch-all route uses @builder.io/sdk (server-side), and builder-registry.ts uses @builder.io/react (client-side). These are different packages. Both need initialization. Do NOT "fix" this by removing one.
4. API key must be in the deployment environment. .env.local works locally but is not deployed. Users must add NEXT_PUBLIC_BUILDER_API_KEY in their hosting platform's environment settings. This is the #1 "it doesn't work in production" cause.
5. Side-effect import path must be correct. import "@/builder-registry" in components/builder.tsx is what triggers component registrations. If this path is wrong, the build succeeds but no custom components appear in the editor. Verify this import first when debugging missing components.
6. Dynamic import path is relative to `builder-registry.ts`. If the registry is at the project root and the component is at components/Hero/Hero.tsx, the import must be ./components/Hero/Hero. Wrong paths cause the component to register but render blank.
7. Double `BuilderDevTools` wrapping. The f2p template has a bug: BuilderDevTools()(BuilderDevTools()(nextConfig)). Always check for existing wrapping before adding it.
8. Next.js 15+ `params` is a Promise. Must be awaited. Next.js 14 uses params directly. Check the version in package.json.
9. `[...page]` does not handle the root path `/`. If the user needs Builder to manage the homepage, they need to add Builder content fetching to app/page.tsx separately, or switch to [[...page]] and remove app/page.tsx.
10. CSP headers may block the visual editor. Builder's editor loads the deployed site in an iframe. If the app sets X-Frame-Options or CSP frame-ancestors, it must allow https://*.builder.io.
When to Use What
| Need | Tool | Why |
|---|---|---|
| Register components for Publish visual editor | This skill | Automates TypeScript → Builder input mapping and scaffolding |
| Help the AI understand your design system | Component Indexing (npx @builder.io/dev-tools index-repo) | Different purpose: improves code generation, not Publish registration |
| Project-wide coding conventions | AGENTS.md | Always loaded, good for conventions all AI tools should follow |
| Directory-scoped rules | .builderrules or .builder/rules/ | Proximity-based, Builder-specific |
| Register components manually | @builder.io/dev-tools UI | Visual registration without this skill |
Anti-Patterns
- Never use `type: "enum"`. Use
type: "text"with anenumarray. - Never skip `defaultValue` on list/object inputs. Builder will error.
- Never use `[[...page]]` if the user has a homepage. It conflicts with
app/page.tsx. - Never write API keys to `.env` (only
.env.local). - Never wrap `BuilderDevTools()` twice.
- Never register event handlers, CSSProperties, or HTML attributes as Builder inputs.
- Never remove either `builder.init()` call. Server and client inits serve different purposes.
Audit Checklist
After registering a component, verify:
- [ ]
builder-registry.tshas"use client"directive - [ ] Dynamic import path resolves to the correct component file
- [ ] All
listinputs havedefaultValue: [] - [ ] All
objectinputs havedefaultValue: {} - [ ] Enum props use
type: "text"withenumarray - [ ] No event handlers, CSSProperties, or HTML attributes in inputs
- [ ]
childrenprop is handled viacanHaveChildren: true, not as an input - [ ] Required props have
required: true - [ ] API key is set in both
.env.localand deployment environment
Scripts
This skill includes helper scripts in scripts/:
- `detect-project.sh` — Detects project structure, SDK version, scaffolding status. Run at the start of any workflow.
- `scan-components.sh` — Scans a directory for React components and checks registration status. Used in Workflow 3.
Reference Files
For complete SDK API reference and type mapping table, see references/sdk-reference.md.
For exact scaffolding file templates, see references/scaffolding-templates.md.
For end-to-end component registration examples, see references/examples.md.
Component Registration Examples
Complete end-to-end examples showing a TypeScript component and its Builder registration.
Example 1: Simple Component (text + number)
Component
// components/PricingCard/PricingCard.tsx
export interface PricingCardProps {
title: string;
price: number;
description?: string;
highlighted?: boolean;
}
export default function PricingCard({
title,
price,
description,
highlighted = false,
}: PricingCardProps) {
return (
<div className={highlighted ? "card highlighted" : "card"}>
<h3>{title}</h3>
<p className="price">${price}/mo</p>
{description && <p>{description}</p>}
</div>
);
}Registration
// In builder-registry.ts
import dynamic from "next/dynamic";
Builder.registerComponent(
dynamic(() => import("./components/PricingCard/PricingCard")),
{
name: "PricingCard",
inputs: [
{
name: "title",
type: "text",
friendlyName: "Title",
required: true,
defaultValue: "Starter Plan",
},
{
name: "price",
type: "number",
friendlyName: "Price",
required: true,
defaultValue: 29,
helperText: "Monthly price in dollars",
},
{
name: "description",
type: "longText",
friendlyName: "Description",
helperText: "Short description of the plan",
},
{
name: "highlighted",
type: "boolean",
friendlyName: "Highlighted",
defaultValue: false,
helperText: "Add a highlight border to this card",
},
],
}
);Why `description` maps to `longText`: The prop name "description" matches the name heuristic for longer text content. For a single-line label, text would be used instead.
---
Example 2: Rich Component (enum, images, colors)
Component
// components/HeroBanner/HeroBanner.tsx
export interface HeroBannerProps {
heading: string;
subheading?: string;
variant: "light" | "dark" | "gradient";
backgroundImage?: string;
overlayColor?: string;
ctaText?: string;
ctaUrl?: string;
}
export default function HeroBanner({
heading,
subheading,
variant = "light",
backgroundImage,
overlayColor,
ctaText,
ctaUrl,
}: HeroBannerProps) {
return (
<section className={`hero hero--${variant}`}>
{backgroundImage && <img src={backgroundImage} alt="" />}
<div style={{ backgroundColor: overlayColor }}>
<h1>{heading}</h1>
{subheading && <p>{subheading}</p>}
{ctaText && <a href={ctaUrl}>{ctaText}</a>}
</div>
</section>
);
}Registration
Builder.registerComponent(
dynamic(() => import("./components/HeroBanner/HeroBanner")),
{
name: "HeroBanner",
inputs: [
{
name: "heading",
type: "text",
friendlyName: "Heading",
required: true,
defaultValue: "Welcome to Our Site",
},
{
name: "subheading",
type: "text",
friendlyName: "Subheading",
},
{
name: "variant",
type: "text",
friendlyName: "Variant",
enum: [
{ label: "Light", value: "light" },
{ label: "Dark", value: "dark" },
{ label: "Gradient", value: "gradient" },
],
defaultValue: "light",
helperText: "Visual theme for the hero section",
},
{
name: "backgroundImage",
type: "file",
friendlyName: "Background Image",
allowedFileTypes: ["jpeg", "jpg", "png", "webp"],
},
{
name: "overlayColor",
type: "color",
friendlyName: "Overlay Color",
helperText: "Semi-transparent color over the background image",
},
{
name: "ctaText",
type: "text",
friendlyName: "CTA Text",
defaultValue: "Get Started",
},
{
name: "ctaUrl",
type: "url",
friendlyName: "CTA URL",
},
],
}
);Key patterns:
variant: "light" | "dark" | "gradient"→type: "text"withenumarray (NOTtype: "enum")backgroundImage→type: "file"(name heuristic: contains "image")overlayColor→type: "color"(name heuristic: contains "color")ctaUrl→type: "url"(name heuristic: contains "url")
---
Example 3: Complex Component (list/object with subFields)
Component
// components/TestimonialGrid/TestimonialGrid.tsx
export interface Testimonial {
quote: string;
author: string;
avatar?: string;
rating: number;
company?: {
name: string;
logo?: string;
};
}
export interface TestimonialGridProps {
title: string;
testimonials: Testimonial[];
columns?: number;
}
export default function TestimonialGrid({
title,
testimonials,
columns = 3,
}: TestimonialGridProps) {
return (
<section>
<h2>{title}</h2>
<div style={{ display: "grid", gridTemplateColumns: `repeat(${columns}, 1fr)` }}>
{testimonials.map((t, i) => (
<div key={i}>
<p>{t.quote}</p>
<div>
{t.avatar && <img src={t.avatar} alt={t.author} />}
<strong>{t.author}</strong>
{t.company && <span>{t.company.name}</span>}
</div>
<div>{"★".repeat(t.rating)}</div>
</div>
))}
</div>
</section>
);
}Registration
Builder.registerComponent(
dynamic(() => import("./components/TestimonialGrid/TestimonialGrid")),
{
name: "TestimonialGrid",
inputs: [
{
name: "title",
type: "text",
friendlyName: "Title",
required: true,
defaultValue: "What Our Customers Say",
},
{
name: "testimonials",
type: "list",
friendlyName: "Testimonials",
defaultValue: [
{
quote: "Amazing product!",
author: "Jane Smith",
rating: 5,
company: { name: "Acme Corp" },
},
],
subFields: [
{ name: "quote", type: "longText", required: true },
{ name: "author", type: "text", required: true },
{
name: "avatar",
type: "file",
allowedFileTypes: ["jpeg", "jpg", "png", "webp"],
},
{ name: "rating", type: "number", min: 1, max: 5, defaultValue: 5 },
{
name: "company",
type: "object",
defaultValue: {},
subFields: [
{ name: "name", type: "text" },
{
name: "logo",
type: "file",
allowedFileTypes: ["svg", "png"],
},
],
},
],
},
{
name: "columns",
type: "number",
friendlyName: "Columns",
defaultValue: 3,
min: 1,
max: 6,
helperText: "Number of columns in the grid",
},
],
}
);Critical patterns:
testimonials: Testimonial[]→type: "list"withdefaultValue: [...](MUST have defaultValue)- Nested
company: { name, logo }→type: "object"withdefaultValue: {}(MUST have defaultValue) rating: numberwithmin: 1, max: 5constraintsquote→longText(name heuristic for longer content)
---
Example 4: Container Component (children + noWrap)
Component
// components/ContentSection/ContentSection.tsx
import { ReactNode } from "react";
export interface ContentSectionProps {
title?: string;
backgroundColor?: string;
maxWidth?: "narrow" | "medium" | "wide" | "full";
children: ReactNode;
}
export default function ContentSection({
title,
backgroundColor,
maxWidth = "medium",
children,
...props
}: ContentSectionProps & { attributes?: Record<string, unknown> }) {
const widthMap = { narrow: "640px", medium: "960px", wide: "1200px", full: "100%" };
return (
<section
style={{ backgroundColor, maxWidth: widthMap[maxWidth], margin: "0 auto" }}
{...(props as any).attributes}
>
{title && <h2>{title}</h2>}
{children}
</section>
);
}Registration
Builder.registerComponent(
dynamic(() => import("./components/ContentSection/ContentSection")),
{
name: "ContentSection",
canHaveChildren: true,
noWrap: true,
inputs: [
{
name: "title",
type: "text",
friendlyName: "Section Title",
},
{
name: "backgroundColor",
type: "color",
friendlyName: "Background Color",
},
{
name: "maxWidth",
type: "text",
friendlyName: "Max Width",
enum: [
{ label: "Narrow (640px)", value: "narrow" },
{ label: "Medium (960px)", value: "medium" },
{ label: "Wide (1200px)", value: "wide" },
{ label: "Full Width", value: "full" },
],
defaultValue: "medium",
},
],
}
);Key patterns:
children: ReactNode→ NOT added as an input. Instead,canHaveChildren: trueis added to the registration. This lets users drag other blocks inside this component in the editor.noWrap: true→ The component renders a<section>(semantic element). WithoutnoWrap, Builder adds a wrapper<div>that breaks the semantic HTML. When usingnoWrap, the component MUST spread{...props.attributes}on its root element.backgroundColor→type: "color"(name heuristic: contains "color")maxWidth: "narrow" | "medium" | "wide" | "full"→type: "text"withenum(union takes precedence over name heuristic)
Scaffolding Templates
Exact file contents generated by Workflow 1. Adapt paths based on detected project structure.
builder-registry.ts (project root)
"use client";
import { builder, Builder } from "@builder.io/react";
// Runtime guard — fail loudly if API key is missing
const apiKey = process.env.NEXT_PUBLIC_BUILDER_API_KEY;
if (!apiKey) {
throw new Error(
"Missing NEXT_PUBLIC_BUILDER_API_KEY environment variable. " +
"Set it in .env.local for local development or in your deployment provider's environment settings."
);
}
builder.init(apiKey);
// --- Component Registrations ---
// Add Builder.registerComponent() calls below this line.
// Each registration makes a React component available as a
// draggable block in Publish's visual editor.
//
// Example:
// import dynamic from "next/dynamic";
// Builder.registerComponent(
// dynamic(() => import("./components/Hero/Hero")),
// { name: "Hero", inputs: [{ name: "title", type: "text" }] }
// );app/[...page]/page.tsx — Catch-All Route
Next.js 15+ (params is a Promise)
import { builder } from "@builder.io/sdk";
import { RenderBuilderContent } from "@/components/builder";
// Server-side SDK initialization (separate from the client-side init in builder-registry.ts)
const apiKey = process.env.NEXT_PUBLIC_BUILDER_API_KEY;
if (!apiKey) {
throw new Error(
"Missing NEXT_PUBLIC_BUILDER_API_KEY. Set it in .env.local or your deployment environment."
);
}
builder.init(apiKey);
interface PageProps {
params: Promise<{ page: string[] }>;
}
export default async function CatchAllPage({ params }: PageProps) {
const resolvedParams = await params;
const urlPath = "/" + (resolvedParams?.page?.join("/") || "");
const content = await builder
.get("page", {
userAttributes: { urlPath },
prerender: false,
})
.toPromise();
return <RenderBuilderContent content={content} model="page" />;
}Next.js 14 (params is a plain object)
import { builder } from "@builder.io/sdk";
import { RenderBuilderContent } from "@/components/builder";
const apiKey = process.env.NEXT_PUBLIC_BUILDER_API_KEY;
if (!apiKey) {
throw new Error(
"Missing NEXT_PUBLIC_BUILDER_API_KEY. Set it in .env.local or your deployment environment."
);
}
builder.init(apiKey);
interface PageProps {
params: { page: string[] };
}
export default async function CatchAllPage({ params }: PageProps) {
const urlPath = "/" + (params?.page?.join("/") || "");
const content = await builder
.get("page", {
userAttributes: { urlPath },
prerender: false,
})
.toPromise();
return <RenderBuilderContent content={content} model="page" />;
}Why two `builder.init()` calls? The catch-all route runs on the server and uses @builder.io/sdk. The builder-registry runs on the client and uses @builder.io/react. These are separate packages with separate state. Both need initialization.
components/builder.tsx — RenderBuilderContent Wrapper
"use client";
import { BuilderComponent, useIsPreviewing } from "@builder.io/react";
import "@/builder-registry";
interface BuilderPageProps {
content: unknown;
model?: string;
}
export function RenderBuilderContent({
content,
model = "page",
}: BuilderPageProps) {
const isPreviewing = useIsPreviewing();
if (content || isPreviewing) {
return <BuilderComponent content={content} model={model} />;
}
return (
<div style={{ padding: "2rem", textAlign: "center" }}>
<h1>404</h1>
<p>Page not found</p>
</div>
);
}Why `import "@/builder-registry"`? This is a side-effect import that triggers all Builder.registerComponent() calls. Without it, no custom components appear in the editor. Use the @/ path alias (configured by default in Next.js) to avoid fragile relative paths.
Why `content: unknown` instead of `any`? The Builder SDK's Gen1 types are loose, but using unknown is safer. BuilderComponent accepts the value without issue.
next.config.ts — BuilderDevTools Wrapping
TypeScript variant
import type { NextConfig } from "next";
import BuilderDevTools from "@builder.io/dev-tools/next";
const nextConfig: NextConfig = {
// existing config
};
export default BuilderDevTools()(nextConfig);JavaScript (CommonJS) variant
const BuilderDevTools = require("@builder.io/dev-tools/next");
/** @type {import('next').NextConfig} */
const nextConfig = {
// existing config
};
module.exports = BuilderDevTools()(nextConfig);JavaScript (ES Modules) variant
import BuilderDevTools from "@builder.io/dev-tools/next";
/** @type {import('next').NextConfig} */
const nextConfig = {
// existing config
};
export default BuilderDevTools()(nextConfig);Always check first: Search the config file for BuilderDevTools or @builder.io/dev-tools. If found, do NOT wrap again. Fix double-wrapping if detected.
.builderrules — Component Creation Conventions
Adapt this template to match the project's existing conventions (e.g., Tailwind vs CSS Modules, naming patterns).
## Component Conventions for Publish Integration
When creating new components:
1. **Directory structure**: Each component in its own directory under `components/`:
- `components/ComponentName/ComponentName.tsx`
- `components/ComponentName/styles.module.css` (if using CSS Modules)
2. **Typed props**: Export a props interface from the component file:export interface ComponentNameProps { title: string; variant?: 'primary' | 'secondary'; }
3. **Default exports**: Use default exports for compatibility with `next/dynamic`.
4. **Builder registration**: After creating a component, register it in `builder-registry.ts` using `Builder.registerComponent()` with dynamic imports and input types mapped from the props interface..env.example
# Builder.io public API key (required)
# Get it from: Builder.io → Space Settings → API Keys
NEXT_PUBLIC_BUILDER_API_KEY=your-builder-public-api-key.gitignore additions
Ensure these lines exist:
.env*.localBuilder.io SDK Reference for Component Registration
Builder.registerComponent() API (Gen1)
import { Builder } from "@builder.io/react";
import dynamic from "next/dynamic";
Builder.registerComponent(
dynamic(() => import("./components/MyComponent/MyComponent")),
{
name: "MyComponent", // Required: unique identifier in the editor
inputs: [...], // Input definitions (see below)
image: "https://...", // Icon URL in the editor's insert menu
canHaveChildren: false, // Enable child element support
defaultChildren: [], // Initial child elements when dropped
defaultStyles: {}, // CSS styles applied by default
noWrap: false, // Render without wrapper div (component must spread props.attributes)
models: ["page"], // Restrict to specific content models
hideFromInsertMenu: false, // Hide from editor (for deprecation)
childRequirements: {}, // Restrict which children are valid
requiresParent: {}, // Restrict which parents are valid
}
);Input Types
Each entry in the inputs array defines a control in Publish's visual editor.
Input Properties
| Property | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Prop name passed to the component |
type | string | Yes | Editor control type (see table below) |
friendlyName | string | No | Display label in the editor |
defaultValue | any | Yes for list/object | Initial value. MUST be [] for list, {} for object |
required | boolean | No | Mark as mandatory |
helperText | string | No | Description shown below the input |
subFields | Input[] | For list/object | Nested field definitions |
enum | string[] or {label, value}[] | For dropdowns | Options for text inputs |
min / max / step | number | No | Constraints for number inputs |
advanced | boolean | No | Place under "show more" section |
showIf | string | No | JS expression to conditionally show |
localized | boolean | No | Enable translation support |
allowedFileTypes | string[] | For file | Restrict file types (e.g., ["jpeg", "png"]) |
model | string | For reference | Restrict reference to specific model |
Available Input Types
| Type | Description | Value Returned |
|---|---|---|
text / string | Single-line text. Add enum for dropdown. | string |
longText | Multi-line textarea | string |
richText | Rich text WYSIWYG editor | HTML string |
number | Numeric input with stepper | number |
boolean | Toggle switch | boolean |
color | Color picker | hex/rgba string |
file | File upload (returns URL) | URL string |
url | URL input | string |
email | Email input | string |
date | Date picker | ISO date string |
list | Repeating items. Requires `subFields` and `defaultValue: []` | array |
object | Nested group. Requires `subFields` and `defaultValue: {}` | object |
reference | Content entry picker | reference object |
tags | Tag/chip input | string[] |
code | Code editor | string |
json | JSON editor | object |
There is no `type: "enum"`. For dropdown selection, use type: "text" with an enum array.
TypeScript to Builder Input Type Mapping
When mapping a component's TypeScript props to Builder inputs, evaluate in this order:
Step 1: Check the TypeScript type
| TypeScript Type | Builder Input | Notes |
|---|---|---|
string | text | Apply name heuristics in Step 2 |
number | number | |
boolean | boolean | |
| `'a' \ | 'b' \ | 'c'` (string literal union) |
| `number \ | string` (mixed union) | text |
string[] | tags | |
T[] / Array<T> | list with subFields | Recurse into T. MUST add defaultValue: [] |
{ key: Type } / interface | object with subFields | Recurse into properties. MUST add defaultValue: {} |
Date | date | |
React.ReactNode / children | Skip | Add canHaveChildren: true to registration instead |
React.CSSProperties | Skip | Don't expose raw CSS |
React.MouseEventHandler etc. | Skip | Event handlers not relevant in Publish |
HTMLAttributes<*> | Skip | Filter out inherited HTML props |
any / unknown | text | Warn the user |
| Custom imported type | Follow the import, resolve the type, then map |
Step 2: Name heuristics (only for plain string props)
Only apply these if Step 1 resolved to plain string (not a union, not a specific type):
| Prop name contains | Map to | Example |
|---|---|---|
color, backgroundColor, borderColor | color | textColor: string → color |
image, src, backgroundImage, avatar, logo, thumbnail | file with allowedFileTypes: ["jpeg", "jpg", "png", "svg", "webp"] | heroImage: string → file |
url, href, link | url | ctaUrl: string → url |
description, body, content, bio, summary | longText | description: string → longText |
html, richContent | richText | bodyHtml: string → richText |
Never apply name heuristics to union types. The union values carry more semantic information than the prop name. colorScheme: 'light' | 'dark' is an enum, not a color picker.
Step 3: Additional mapping rules
- Optional prop (
name?: Type) →required: false - Required prop (
name: Type) →required: true - Destructured default (
{ name = 'default' }) →defaultValue: 'default' - Generate
friendlyNamefrom PascalCase:backgroundColor→"Background Color" - Generate
helperTextfor non-obvious inputs (5-10 words describing what it controls) - Use
advanced: trueforclassName,id,style,aria-*props - If the component renders a semantic root element (
<section>,<header>,<nav>), considernoWrap: trueon the registration. When usingnoWrap, the component must spread{...props.attributes}on its root element.
SDK Packages (Important Distinction)
| Package | Use For | Import |
|---|---|---|
@builder.io/react | Client-side: component registration, BuilderComponent, useIsPreviewing | builder-registry.ts, components/builder.tsx |
@builder.io/sdk | Server-side: content fetching, builder.get() | app/[...page]/page.tsx |
@builder.io/dev-tools | Build tool: wraps next.config for visual editing integration | next.config.ts |
Both @builder.io/react and @builder.io/sdk need their own builder.init() call. They are separate SDK entry points.
External Documentation
#!/bin/bash
# detect-project.sh — Detect project state for Fusion-to-Publish skill
# Usage: bash .builder/skills/fusion-to-publish-v2/scripts/detect-project.sh
echo "=== Fusion-to-Publish: Project State ==="
echo ""
# SDK detection
if command -v node &>/dev/null && [ -f package.json ]; then
SDK=$(node -e "
const p = JSON.parse(require('fs').readFileSync('package.json', 'utf8'));
const d = p.dependencies || {};
if (d['@builder.io/react']) console.log('@builder.io/react (Gen1) ' + d['@builder.io/react']);
else if (d['@builder.io/sdk-react']) console.log('@builder.io/sdk-react (Gen2) ' + d['@builder.io/sdk-react']);
else console.log('NOT INSTALLED');
" 2>/dev/null)
echo "Builder SDK: $SDK"
NEXT=$(node -e "
const p = JSON.parse(require('fs').readFileSync('package.json', 'utf8'));
console.log(p.dependencies?.next || 'not found');
" 2>/dev/null)
echo "Next.js: $NEXT"
else
echo "Builder SDK: cannot detect (no node or package.json)"
echo "Next.js: cannot detect"
fi
# App root detection
if [ -d "src/app" ]; then
echo "App root: src/app/"
elif [ -d "app" ]; then
echo "App root: app/"
else
echo "App root: NOT FOUND"
fi
# Registry detection
if [ -f "builder-registry.ts" ]; then
COUNT=$(grep -c "registerComponent" builder-registry.ts 2>/dev/null || echo "0")
echo "Registry: builder-registry.ts ($COUNT components registered)"
elif [ -f "src/builder-registry.ts" ]; then
COUNT=$(grep -c "registerComponent" src/builder-registry.ts 2>/dev/null || echo "0")
echo "Registry: src/builder-registry.ts ($COUNT components registered)"
else
echo "Registry: NOT FOUND"
fi
# Catch-all route detection
CATCHALL=""
for path in "app/[...page]/page.tsx" "src/app/[...page]/page.tsx" "app/[[...page]]/page.tsx" "src/app/[[...page]]/page.tsx"; do
if [ -f "$path" ]; then
CATCHALL="$path"
break
fi
done
if [ -n "$CATCHALL" ]; then
echo "Catch-all route: $CATCHALL"
else
echo "Catch-all route: NOT FOUND"
fi
# API key detection
if grep -q "NEXT_PUBLIC_BUILDER_API_KEY" .env.local 2>/dev/null; then
echo "API key: configured (.env.local)"
elif grep -q "NEXT_PUBLIC_BUILDER_API_KEY" .env 2>/dev/null; then
echo "API key: configured (.env) — WARNING: may be committed to version control"
else
echo "API key: NOT CONFIGURED"
fi
# Dev-tools wrapper detection
DEVTOOLS="no"
for cfg in next.config.ts next.config.js next.config.mjs; do
if [ -f "$cfg" ] && grep -q "BuilderDevTools\|@builder.io/dev-tools" "$cfg" 2>/dev/null; then
DEVTOOLS="yes ($cfg)"
# Check for double wrapping
if grep -q "BuilderDevTools()(BuilderDevTools()" "$cfg" 2>/dev/null; then
DEVTOOLS="yes ($cfg) — WARNING: double-wrapped!"
fi
break
fi
done
echo "Dev-tools wrapper: $DEVTOOLS"
# RenderBuilderContent detection
if [ -f "components/builder.tsx" ] || [ -f "src/components/builder.tsx" ]; then
echo "RenderBuilderContent: found"
else
echo "RenderBuilderContent: NOT FOUND"
fi
# .builderrules detection
if [ -f ".builderrules" ]; then
echo ".builderrules: found"
else
echo ".builderrules: NOT FOUND"
fi
echo ""
echo "=== Summary ==="
MISSING=0
[ "$SDK" = "NOT INSTALLED" ] && echo " - Need to install Builder SDK" && MISSING=$((MISSING+1))
[ ! -f "builder-registry.ts" ] && [ ! -f "src/builder-registry.ts" ] && echo " - Need to create builder-registry.ts" && MISSING=$((MISSING+1))
[ -z "$CATCHALL" ] && echo " - Need to create catch-all route" && MISSING=$((MISSING+1))
if ! grep -q "NEXT_PUBLIC_BUILDER_API_KEY" .env.local 2>/dev/null && ! grep -q "NEXT_PUBLIC_BUILDER_API_KEY" .env 2>/dev/null; then
echo " - Need to configure API key" && MISSING=$((MISSING+1))
fi
[ "$DEVTOOLS" = "no" ] && echo " - Need to wrap next.config with BuilderDevTools" && MISSING=$((MISSING+1))
if [ $MISSING -eq 0 ]; then
echo " All scaffolding is in place. Ready to register components."
else
echo " $MISSING scaffolding step(s) needed. Run Workflow 1."
fi
#!/bin/bash
# scan-components.sh — Scan a directory for React components and check registration status
# Usage: bash .builder/skills/fusion-to-publish-v2/scripts/scan-components.sh [directory] [registry-file]
DIR="${1:-components}"
REGISTRY="${2:-builder-registry.ts}"
if [ ! -d "$DIR" ]; then
echo "Directory not found: $DIR"
echo "Usage: scan-components.sh [components-directory] [builder-registry-file]"
exit 1
fi
echo "=== Component Scan: $DIR ==="
echo "Registry: $REGISTRY"
echo ""
TOTAL=0
REGISTERED=0
UNREGISTERED=0
for file in $(find "$DIR" -name "*.tsx" -o -name "*.jsx" | grep -v '\.test\.' | grep -v '\.spec\.' | grep -v '\.stories\.' | grep -v '\.story\.' | grep -v '\.mock\.' | grep -v '\.d\.ts' | sort); do
# Skip barrel files (index files that only re-export)
BASENAME=$(basename "$file")
if [ "$BASENAME" = "index.tsx" ] || [ "$BASENAME" = "index.jsx" ]; then
# Check if it's a real component or just a barrel
if ! grep -qE "export (default )?(function|const) [A-Z]" "$file" 2>/dev/null; then
continue
fi
fi
# Check if it exports a function component (PascalCase)
if grep -qE "export (default )?(function|const) [A-Z]" "$file" 2>/dev/null; then
COMPONENT_NAME=$(grep -oE "export (default )?(function|const) [A-Z][a-zA-Z]+" "$file" | head -1 | grep -oE "[A-Z][a-zA-Z]+$")
if [ -z "$COMPONENT_NAME" ]; then
continue
fi
TOTAL=$((TOTAL+1))
# Check if already registered
if [ -f "$REGISTRY" ] && grep -q "name: [\"']${COMPONENT_NAME}[\"']" "$REGISTRY" 2>/dev/null; then
STATUS="REGISTERED"
REGISTERED=$((REGISTERED+1))
echo "[$STATUS] $COMPONENT_NAME"
echo " File: $file"
else
STATUS="UNREGISTERED"
UNREGISTERED=$((UNREGISTERED+1))
echo "[$STATUS] $COMPONENT_NAME"
echo " File: $file"
# Show the props interface for unregistered components
PROPS=$(grep -A 30 "export interface.*Props" "$file" 2>/dev/null | sed '/^}/q')
if [ -n "$PROPS" ]; then
echo " Props:"
echo "$PROPS" | sed 's/^/ /'
else
# Try type alias
PROPS=$(grep -A 30 "export type.*Props" "$file" 2>/dev/null | sed '/^}/q')
if [ -n "$PROPS" ]; then
echo " Props:"
echo "$PROPS" | sed 's/^/ /'
else
echo " Props: (not found — check component file manually)"
fi
fi
fi
echo ""
fi
done
echo "=== Summary ==="
echo "Total components: $TOTAL"
echo "Already registered: $REGISTERED"
echo "Need registration: $UNREGISTERED"
if [ $UNREGISTERED -eq 0 ] && [ $TOTAL -gt 0 ]; then
echo "All components are registered."
elif [ $TOTAL -eq 0 ]; then
echo "No components found in $DIR."
fi