You're reading docs for Nextly Alpha. APIs may change between releases.

Page Builder

Blocks from plugins

Contribute blocks to the page builder from a plugin, give a block its own inspector, and write block code that works inside the editor's canvas.

A plugin can add blocks to the page builder. Its blocks then appear in the editor's Add panel and its search, get an inspector like the core blocks, render on the published site, and validate on save. This is experimental: the contract may change before the page builder is stable.

Declare the blocks

Declare the blocks for the page builder in contributes.declarations, and name an editorEntry: a module that exports the same definitions for the editor.

// my-blocks/blocks.ts
import { defineBlock } from "@nextlyhq/plugin-sdk/blocks";
import { createElement } from "react";

export const callout = defineBlock<{ text?: string }>({
  name: "acme/callout",
  version: 1,
  description: "A short highlighted note.",
  editor: { label: "Callout", category: "Content", keywords: ["note"] },
  props: { text: { type: "text", role: "content" } },
  example: { props: { text: "Remember this." } },
  render: ({ props, className }) =>
    createElement("aside", { className }, props.text ?? ""),
});

export const acmeBlocks = { blocks: [callout], supports: [] };
// my-blocks/editor.tsx
"use client";

export { acmeBlocks } from "./blocks";
// my-blocks/plugin.ts
import { definePlugin } from "@nextlyhq/plugin-sdk";

import { acmeBlocks } from "./blocks";

export const acmeBlocksPlugin = definePlugin({
  name: "@acme/blocks",
  version: "1.0.0",
  nextly: "*",
  contributes: {
    declarations: {
      "@nextlyhq/plugin-page-builder": {
        ...acmeBlocks,
        editorEntry: "@acme/blocks/editor#acmeBlocks",
      },
    },
  },
});

Both halves are needed. The server registers the declared blocks when your app boots, which is what validates and renders them. The editor runs in the admin's browser bundle, and it receives your blocks from the editorEntry: npx nextly generate:types writes the admin's component map, which imports that module and hands its blocks to the editor. Run it after adding or changing a declaration; next dev rewrites the map for you when it falls behind.

Export the same objects from both, as above. The editor registers your supports before your blocks, so a block may use a support another plugin declares. A block named like a core block (core/...) is refused in the editor, with a warning naming both.

PageBuilderDeclaration and PageBuilderEditorEntry from @nextlyhq/plugin-page-builder/blocks type the two objects.

Blocks registered in code reach the server only

blockRegistry(ctx).register() from a plugin's init still works, but the editor never sees a block registered that way: nothing can name its module before the plugin runs, so the component map cannot import it. In development, the first registration of each such block logs a warning naming it. Declare it instead.

Give a block its own inspector

By default a block's inspector is generated from its props. To draw your own, name a component in editor.component. It replaces the generated content controls only: the block's name and lock, its CSS id and attributes, and its Style tab stay as they are for every block.

// my-blocks/editor.tsx
"use client";

import type { BlockInspectorProps } from "@nextlyhq/plugin-sdk/blocks";

export function CalloutInspector({
  props,
  commit,
  readOnly,
}: BlockInspectorProps<{ text?: string }>) {
  return (
    <input
      aria-label="Callout text"
      defaultValue={props.text ?? ""}
      disabled={readOnly}
      onBlur={event => commit("text", event.target.value)}
    />
  );
}
editor: { label: "Callout", component: "@acme/blocks/editor#CalloutInspector" },

The component receives:

PropWhat it is
nodeThe selected block as the document stores it.
propsIts props.
commitcommit(name, value) sets one prop, through the same path the generated controls write.
readOnlytrue while the block is locked in Layers.

While readOnly is true the editor refuses every commit, whatever your component does, so draw your controls disabled. Inside an area locked "contentOnly", the editor accepts a commit only for a prop declared role: "content", as it does for the generated controls. If your component is not in the component map, or throws while it draws, the block gets its generated controls instead.

Block code inside the editor's canvas

The editor draws blocks in its canvas, which is meant to become a frame of its own. A block whose code measures itself, listens to the window, or reads the viewport must use the document and window it is drawn in, not the globals: either its own element's ownerDocument and defaultView, or useBlockEnvironment().

"use client";

import { useBlockEnvironment } from "@nextlyhq/blocks-react/environment";
import { useEffect, useState } from "react";

export function ViewportWidth() {
  const environment = useBlockEnvironment();
  const [width, setWidth] = useState<number>();
  useEffect(() => {
    const view = environment?.window;
    if (view === undefined) return;
    const measure = () => setWidth(view.innerWidth);
    measure();
    view.addEventListener("resize", measure);
    return () => view.removeEventListener("resize", measure);
  }, [environment]);
  return <span>{width ?? ""}</span>;
}

In the editor the hook returns the canvas's document and window. On the published site it returns the page's own once the block has mounted, and undefined while it renders on the server, so read it in an effect. A block that reads the globals still renders in both places; only its measurements and listeners would target the admin page instead of the canvas.