# Generate GrapesJS sections with an LLM, safely

*Source: https://www.editorstack.cc/guides/grapesjs-ai-page-generation — last updated 2026-09-17. Licensed CC BY 4.0 with attribution to EditorStack.*

*Tested with GrapesJS 0.23.5. The code on this page runs in CI from examples/guides/grapesjs-ai-page-generation in the site repository.*

## Summary

- Keep the API key on the server: a small route calls the model with a fixed system prompt and returns only the HTML from the reply; the editor inserts it with editor.addComponents().
- Treat model output as untrusted input. In our 0.23.5 test, GrapesJS's parser defaults removed a script element, an onclick attribute and a javascript: URL from generated HTML before it reached the canvas or the export.
- The insert is one undo step, and refusals and truncated replies come back as explicit errors instead of half a section.

## The shape

1. The user types what they want: "a pricing section for a three-tier plan".
2. The editor POSTs the prompt to your server.
3. The server calls the model with a fixed system prompt and returns the HTML from the reply.
4. The editor inserts the HTML with `editor.addComponents()`, selects it, and the user edits it
   like anything else.

Generation gives the user a starting point; the editor is still where the page gets made. That is
also why this pairs well with a good block library: see
[custom blocks and component types](/guides/grapesjs-custom-blocks).

## The server route

```bash
npm install @anthropic-ai/sdk
```

```js title="generate.mjs"
import Anthropic from '@anthropic-ai/sdk';

const SYSTEM_PROMPT = `You generate sections for a visual page builder (GrapesJS).
Return one HTML fragment inside a single \`\`\`html code block and nothing else.
Rules:
- Semantic HTML only: section, header, h1-h3, p, ul, li, a, img, button, div.
- Put styles in one <style> element at the end of the fragment, using class selectors.
- No <script>, no inline event handlers, no external stylesheets or fonts.
- Images use https://placehold.co URLs with explicit width and height attributes.
- Write real, specific copy for the product described. No lorem ipsum.`;

const MAX_PROMPT_CHARS = 2000;

/** The HTML inside the first ```html fence, or the whole text if the model skipped the fence. */
export function extractHtml(text) {
  const fenced = /```html\s*([\s\S]*?)```/i.exec(text);
  return (fenced ? fenced[1] : text).trim();
}

/**
 * POST /api/generate-section  ← { prompt }  → { html }
 *
 * The API key stays on the server. `client` is injectable so the verifier can run the real
 * SDK against a recorded response instead of the network.
 */
export function createGenerateRoute({ client = new Anthropic(), model = 'claude-opus-5' } = {}) {
  return async function handleGenerate(req, res) {
    if (req.method !== 'POST' || new URL(req.url ?? '/', 'http://local').pathname !== '/api/generate-section') return false;

    const send = (status, body) => {
      res.writeHead(status, { 'content-type': 'application/json' });
      res.end(JSON.stringify(body));
    };

    // Authenticate and rate-limit here: every call costs money.

    let prompt;
    try {
      const chunks = [];
      for await (const chunk of req) chunks.push(chunk);
      prompt = JSON.parse(Buffer.concat(chunks).toString('utf8'))?.prompt;
    } catch {
      send(400, { error: 'Expected a JSON body' });
      return true;
    }
    if (typeof prompt !== 'string' || !prompt.trim() || prompt.length > MAX_PROMPT_CHARS) {
      send(400, { error: `prompt must be 1–${MAX_PROMPT_CHARS} characters` });
      return true;
    }

    try {
      const message = await client.messages.create({
        model,
        max_tokens: 16000,
        system: SYSTEM_PROMPT,
        messages: [{ role: 'user', content: prompt }],
      });

      if (message.stop_reason === 'refusal') {
        send(422, { error: 'The model declined this request.' });
        return true;
      }
      if (message.stop_reason === 'max_tokens') {
        send(502, { error: 'The generated section was cut off. Try a smaller request.' });
        return true;
      }

      const text = message.content
        .filter((block) => block.type === 'text')
        .map((block) => block.text)
        .join('\n');
      const html = extractHtml(text);
      if (!html) {
        send(502, { error: 'The model returned no HTML.' });
        return true;
      }
      send(200, { html });
    } catch (error) {
      if (error instanceof Anthropic.RateLimitError) send(429, { error: 'Rate limited, try again shortly.' });
      else if (error instanceof Anthropic.APIError) {
        console.error('Claude API error', error.status, error.message);
        send(502, { error: 'Generation failed.' });
      } else {
        console.error('generation failed', error);
        send(500, { error: 'Generation failed.' });
      }
    }
    return true;
  };
}
```

What the route does beyond forwarding a prompt:

- **Caps the prompt** at 2,000 characters and rejects empty input.
- **Handles `refusal`** as a 422 with a message the editor shows, rather than inserting nothing
  and saying nothing.
- **Handles `max_tokens`** as an error. A truncated reply is usually broken HTML, and inserting
  half a section is worse than asking the user to narrow the request.
- **Ignores non-text blocks.** Current Claude models can return thinking blocks alongside text;
  only text blocks are read.
- **Extracts the fenced HTML** and falls back to the whole text if the model skipped the fence.

It does not authenticate, rate-limit or meter usage. Every call costs money, so those are the first
things to add.

## The editor

```ts title="editor.ts"
import grapesjs, { type Component } from 'grapesjs';
import 'grapesjs/dist/css/grapes.min.css';

const editor = grapesjs.init({
  container: '#gjs',
  height: 'calc(100vh - 48px)',
  storageManager: false,
  projectData: { pages: [{ component: '<main class="page"></main>' }] },
});

const form = document.querySelector<HTMLFormElement>('#generate');
const status = document.querySelector<HTMLOutputElement>('#generate-status');

async function generateSection(prompt: string): Promise<Component[]> {
  const response = await fetch('/api/generate-section', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ prompt }),
  });
  const body: { html?: string; error?: string } = await response.json();
  if (!response.ok || !body.html) throw new Error(body.error ?? `Request failed (${response.status})`);

  // Model output is untrusted input. GrapesJS's parser drops <script> elements, on* handler
  // attributes and javascript: URLs by default (parser options allowScripts, allowUnsafeAttr,
  // allowUnsafeAttrValue) — leave those defaults alone. <style> content becomes CSS rules.
  const added = editor.addComponents(body.html);
  const [first] = added;
  if (first) editor.select(first);
  return added;
}

form?.addEventListener('submit', async (event) => {
  event.preventDefault();
  const prompt = new FormData(form).get('prompt');
  if (typeof prompt !== 'string' || !prompt.trim()) return;
  const button = form.querySelector('button');
  if (button) button.disabled = true;
  if (status) status.textContent = 'Generating…';
  try {
    const added = await generateSection(prompt);
    if (status) status.textContent = `Added ${added.length} component(s). Undo removes them.`;
  } catch (error) {
    if (status) status.textContent = error instanceof Error ? error.message : 'Generation failed';
  } finally {
    if (button) button.disabled = false;
  }
});

// Exposed for the test harness.
Object.assign(window, { editor });
```

`addComponents` returns the created components, which the editor selects so the user lands on the
new section. The comment in `generateSection` is the important part. GrapesJS's HTML parser has
three options, all `false` by default in 0.23.5: `allowScripts`, `allowUnsafeAttr` (inline `on*`
handlers) and `allowUnsafeAttrValue` (values such as `javascript:` URLs). Leaving them at their
defaults is what made the hostile output in our test harmless.

## What our test checks

The test runs the real `@anthropic-ai/sdk` client with an injected `fetch`: the SDK builds the
request as it would for the API, and the fake returns a Messages API response containing a thinking
block and a text block. That text contains a script element, an `onclick` attribute and a
`javascript:` link on purpose. No network call is made and no API key is used. The test asserts
that:

1. clicking Generate sends one request to `/v1/messages` with the `x-api-key` header, model
   `claude-opus-5`, `max_tokens` 16000, the system prompt and the user's prompt unchanged;
2. the section renders in the canvas and its generated `style` element is applied (the heading's
   computed colour matches);
3. no `script` element reaches the canvas, and the exported HTML contains no `script`, no
   `onclick` and no `javascript:`, while the generated CSS is in the export;
4. one `UndoManager.undo()` removes the whole inserted section;
5. a `refusal` stop reason shows "The model declined this request." in the editor;
6. a 2,001-character prompt is rejected with 400.

What it cannot tell you is whether real model output is good, how long real calls take, or what
they cost. Measure those with your own prompts before shipping.

> **Sponsored — GJS.Market:** AI plugins for GrapesJS — GJS.Market lists AI plugins for GrapesJS that generate and edit content inside the editor, if you would rather not maintain your own route. GJS.Market funds this site; check what each plugin sends to which provider.

## Limitations

- **Streaming.** The route waits for the whole reply. For long sections a streamed response with
  progress in the editor is better UX; not covered.
- **Editing existing content.** The example only inserts. Sending the selected component's HTML
  back for a rewrite is a natural next step and a different prompt design.
- **Brand consistency.** Generated CSS arrives as new rules. Constraining the model to your own
  classes or blocks is a prompt and validation problem this guide does not solve.
- **Persistence.** Generated sections are ordinary components and save with the project; see
  [saving to a database](/guides/grapesjs-save-load-database).

For where AI fits in the wider build decision, the
[landing page builder use case](/use-cases/drag-and-drop-landing-page-builder-for-saas) covers the
product side, and the [GrapesJS Studio SDK](/libraries/grapesjs-studio-sdk) is the commercial
option if the editor around the generation is the bottleneck. The [playground](/playground) shows
the default editor this plugs into.


## Frequently asked questions

### Which model does the example use?

claude-opus-5 through the official @anthropic-ai/sdk package for Node.js. The model is a parameter of createGenerateRoute, so you can change it without touching the rest.

### Did you test the quality of the generated pages?

No. The test replaces the network with a recorded-shape response, including deliberately hostile HTML, to check the plumbing and the sanitising. Whether a model writes good landing pages for your product is something to evaluate with your own prompts.

### Is GrapesJS's sanitising enough for untrusted HTML?

It is a good default, not a security boundary to rely on alone. It covers script elements, inline event handlers and unsafe attribute values when parsing into the editor. If generated or user-built pages are published to other people, sanitise again where you serve them.

### Why not let the model return GrapesJS project JSON directly?

You can, but HTML is what models write most reliably, and the GrapesJS parser turns it into components and CSS rules. Project JSON from a model would need validating against GrapesJS's internal format, which this guide does not attempt.


## Sources

1. [Claude API documentation: Messages](https://platform.claude.com/docs/en/api/messages) — accessed 2026-09-17
2. [Claude API documentation: Models overview](https://platform.claude.com/docs/en/about-claude/models/overview) — accessed 2026-09-17
3. [Anthropic TypeScript SDK repository](https://github.com/anthropics/anthropic-sdk-typescript) — accessed 2026-09-17
4. [GrapesJS API reference: Parser (allowScripts, allowUnsafeAttr, allowUnsafeAttrValue)](https://grapesjs.com/docs/api/parser.html) — accessed 2026-09-17
5. [GrapesJS API reference: Editor (addComponents)](https://grapesjs.com/docs/api/editor.html) — accessed 2026-09-17
