Skip to content
EditorStackPlayground

Generate GrapesJS sections with an LLM, safely

A server route that asks Claude for an HTML section and an editor button that inserts the result as undoable GrapesJS components. Covers what GrapesJS's parser strips from untrusted model output by default, refusals and truncation. The SDK request path is tested; the model's output quality is not.

Updated · Tested with GrapesJS 0.23.5

TL;DR

  • 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.

The server route

npm install @anthropic-ai/sdk
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

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.

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.

For where AI fits in the wider build decision, the landing page builder use case covers the product side, and the GrapesJS Studio SDK is the commercial option if the editor around the generation is the bottleneck. The 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 — accessed 2026-09-17
  2. Claude API documentation: Models overview — accessed 2026-09-17
  3. Anthropic TypeScript SDK repository — accessed 2026-09-17
  4. GrapesJS API reference: Parser (allowScripts, allowUnsafeAttr, allowUnsafeAttrValue) — accessed 2026-09-17
  5. GrapesJS API reference: Editor (addComponents) — accessed 2026-09-17

Written by the EditorStack Research Team. How we test and what we refuse to publish is on the methodology page.