Integrating GrapesJS into a Vue 3 App β€” Complete Guide for 2026

Build a Vue 3 landing page builder with GrapesJS and Vite. Custom blocks, composables, remote storage, TypeScript & production tips. Updated for 2026.

DevFuture Development
DevFuture Development
October 31, 2025 β€’ 7 months ago
34 min read4309 views

Updated May 2026: migrated to Vite, refreshed Storage API, added TypeScript and Production sections

Why this guide exists (and what changed since 2025)

GrapesJS has been the go-to open-source framework for visual page builders for years, but a lot of older tutorials still ship code that simply won't work today:

  • Vue CLI / Webpack are deprecated for new projects.Vue's official recommendation since 2022 isΒ Vite. New projects should usenpm create vue@latestβ€” notvue create.
  • The GrapesJS Storage API was refactoredΒ in v0.20.x. Fields likeurlStore,urlLoad, and top-levelstepsBeforeSavemoved intooptions.remote.*, andeditor.store()/editor.load()are nowasync.
  • editor.Blocksis the new canonical nameΒ for what used to beeditor.BlockManager(both still work, but the docs and examples useBlocks).
  • <script setup>with the Composition APIΒ is the default style for Vue 3 β€” and it's a much better fit for GrapesJS than the old Options API.

This guide is the version we wish existed when we first integrated GrapesJS into a Vue 3 app. It walks from a fresh Vite scaffold to a production-ready builder with custom blocks, remote storage, reactive editor state, and a clean architecture you can hand to a team.

Looking for Webpack / Vue CLI instructions?Β They still work β€” see theΒ Vue CLI compatibility notesΒ at the end. The core GrapesJS code is identical; only the scaffolding differs.


What you're building

A drag-and-drop landing page builder embedded in a Vue 3 app. By the end you'll have:

  • A<PageBuilder>Vue component that hosts the GrapesJS editor
  • AuseGrapesEditorcomposable that exposes a reactiveeditorinstance and helper methods
  • Custom blocks (hero section, pricing table, CTA, testimonial) registered in a single file
  • Local storage in dev, remote persistence to your backend in production
  • Custom toolbar buttons (Save, Clear, Export HTML)
  • A dark theme override
  • Responsive previews (Desktop / Tablet / Mobile)

Everything is plain Vue 3 + Vite β€” no SSR gotchas, no framework hacks.

Project setup (Vue 3 + Vite)

We use the official scaffolding tool. It gives you Vue 3, Vite, and (optionally) TypeScript, Vue Router, and Pinia out of the box.

npm create vue@latest grapesjs-vue-app 

The CLI asks a few questions. For this guide:

βœ” Project name: grapesjs-vue-app βœ” Add TypeScript? … No (or Yes β€” both are covered below) βœ” Add JSX Support? … No βœ” Add Vue Router for Single Page Application development? … No βœ” Add Pinia for state management? … No βœ” Add Vitest for Unit Testing? … No βœ” Add an End-to-End Testing Solution? … No βœ” Add ESLint for code quality? … Yes βœ” Add Prettier for code formatting? … Yes 

Then install dependencies and start dev mode:

cd grapesjs-vue-app npm install npm run dev 

You should see Vite serving the starter onhttp://localhost:5173.

Opensrc/App.vueand strip it down to the bare minimum β€” we want a blank canvas:

<!-- src/App.vue --> <script setup> import PageBuilder from './components/PageBuilder.vue' </script> <template> <PageBuilder /> </template> <style> html, body, #app { margin: 0; height: 100%; } </style> 

You can also deletesrc/components/HelloWorld.vue,TheWelcome.vue, and friends β€” we're starting clean.


Installing GrapesJS

Two packages: the core library and the webpage preset (basic blocks for landing pages).

npm install grapesjs grapesjs-preset-webpage 

TypeScript users:GrapesJS ships its own type definitions since v0.21 β€” no need for@types/grapesjs. The preset plugin does not, but a minimaldeclare module 'grapesjs-preset-webpage'shim is enough (seeΒ TypeScript notes).


Mounting the editor inside a Vue component

Createsrc/components/PageBuilder.vue. The two non-obvious bits:

  1. Initialise insideonMounted.The containerrefisnulluntil the component is in the DOM.
  2. Destroy on unmount.GrapesJS attaches global listeners and creates an iframe β€” leaving it dangling causes memory leaks when navigating away.
<!-- src/components/PageBuilder.vue --> <script setup> import { ref, onMounted, onBeforeUnmount, shallowRef } from 'vue' import grapesjs from 'grapesjs' import presetWebpage from 'grapesjs-preset-webpage' import 'grapesjs/dist/css/grapes.min.css' import 'grapesjs-preset-webpage/dist/grapesjs-preset-webpage.min.css' const editorContainer = ref(null) // shallowRef β€” GrapesJS instances are large and self-managed; deep reactivity would be wasteful and could break internal state const editor = shallowRef(null) onMounted(() => { editor.value = grapesjs.init({ container: editorContainer.value, height: '100vh', width: 'auto', fromElement: false, // New Storage API (v0.20+) storageManager: { type: 'local', autosave: true, autoload: true, stepsBeforeSave: 1, options: { local: { key: 'gjs-project' } } }, plugins: [presetWebpage], pluginsOpts: { [presetWebpage]: { // Plugin options here β€” see "Custom blocks" section } } }) }) onBeforeUnmount(() => { editor.value?.destroy() }) </script> <template> <div ref="editorContainer" class="page-builder" /> </template> <style scoped> .page-builder { width: 100%; height: 100vh; } </style> 

WhyshallowRefinstead ofref?GrapesJS instances are large, contain circular references, and manage their own state internally. Wrapping one in a deeply reactiverefmakes Vue traverse and proxy every internal property β€” slow at best, broken at worst.shallowRefkeeps the reference reactive but leaves the value alone.This is the single most common mistakein Vue + GrapesJS integrations.

Why pass the plugin function instead of its string name?Two reasons. First, the string-name form (plugins: ['gjs-preset-webpage']) requires the plugin to register itself on a global, which is brittle with tree-shaking bundlers like Vite. Second, passing the imported function directly is type-safe and works with code splitting. The plugin'spluginsOptskey in that case is the function itself β€” that's what[presetWebpage](computed property name) is doing above.

At this pointnpm run devshould show a working GrapesJS editor: panels on the sides, an empty canvas in the middle, and the device-preview buttons up top.


Wrapping GrapesJS in a composable (useGrapesEditor)

Putting everything insidePageBuilder.vueworks for a demo, but for anything real you want the editor logic in a composable. This buys you:

  • Reusability across components (e.g. a separate toolbar that triggerseditor.runCommand)
  • Testability (you can mock the composable)
  • A clean place to expose reactive state (current device, dirty flag, selected component)

Createsrc/composables/useGrapesEditor.js:

// src/composables/useGrapesEditor.js import { ref, shallowRef, onMounted, onBeforeUnmount } from 'vue' import grapesjs from 'grapesjs' import presetWebpage from 'grapesjs-preset-webpage' import 'grapesjs/dist/css/grapes.min.css' import 'grapesjs-preset-webpage/dist/grapesjs-preset-webpage.min.css' export function useGrapesEditor(containerRef, options = {}) { const editor = shallowRef(null) const isReady = ref(false) const isDirty = ref(false) const currentDevice = ref('Desktop') onMounted(() => { editor.value = grapesjs.init({ container: containerRef.value, height: '100vh', width: 'auto', fromElement: false, storageManager: options.storage ?? { type: 'local', autosave: true, autoload: true, options: { local: { key: 'gjs-project' } } }, plugins: [presetWebpage, ...(options.plugins ?? [])], pluginsOpts: options.pluginsOpts ?? {} }) // Mark dirty on any change editor.value.on('component:add component:update component:remove style:update', () => { isDirty.value = true }) // Reset dirty on successful save editor.value.on('storage:end:store', () => { isDirty.value = false }) // Track current device editor.value.on('change:device', () => { currentDevice.value = editor.value.getDevice() }) isReady.value = true options.onReady?.(editor.value) }) onBeforeUnmount(() => { editor.value?.destroy() editor.value = null }) // Helpers const save = () => editor.value?.store() const load = () => editor.value?.load() const getHtml = () => editor.value?.getHtml() ?? '' const getCss = () => editor.value?.getCss() ?? '' const getProjectData = () => editor.value?.getProjectData() const loadProjectData = (data) => editor.value?.loadProjectData(data) const setDevice = (name) => editor.value?.setDevice(name) return { editor, isReady, isDirty, currentDevice, save, load, getHtml, getCss, getProjectData, loadProjectData, setDevice } } 

NowPageBuilder.vueshrinks to almost nothing:

<!-- src/components/PageBuilder.vue --> <script setup> import { ref } from 'vue' import { useGrapesEditor } from '@/composables/useGrapesEditor' import { registerCustomBlocks } from '@/builder/blocks' const editorContainer = ref(null) const { editor, isDirty, save } = useGrapesEditor(editorContainer, { onReady: (ed) => registerCustomBlocks(ed) }) </script> <template> <div class="builder-shell"> <header class="toolbar"> <button :disabled="!isDirty" @click="save"> {{ isDirty ? 'Save changes' : 'Saved βœ“' }} </button> </header> <div ref="editorContainer" class="canvas" /> </div> </template> <style scoped> .builder-shell { display: flex; flex-direction: column; height: 100vh; } .toolbar { padding: 8px 16px; background: #1f2937; color: #fff; } .canvas { flex: 1; min-height: 0; } </style> 

Notice the toolbar button is aVuebutton outside the GrapesJS UI, driven by the reactiveisDirtyflag. This is the pattern:Β Vue owns the chrome, GrapesJS owns the canvas.


Creating custom blocks

Built-in blocks from the webpage preset cover the basics (text, image, video, columns). Your own blocks are where the builder starts to feel likeyours.

Put each block in its own module so the file doesn't become a 2,000-line wall:

// src/builder/blocks/index.js import { heroBlock } from './hero' import { pricingBlock } from './pricing' import { ctaBlock } from './cta' import { testimonialBlock } from './testimonial' export function registerCustomBlocks(editor) { const blocks = editor.Blocks ;[heroBlock, pricingBlock, ctaBlock, testimonialBlock].forEach((b) => blocks.add(b.id, b.definition) ) } 
// src/builder/blocks/hero.js export const heroBlock = { id: 'hero-section', definition: { label: 'Hero', category: 'Sections', media: `<svg viewBox="0 0 24 24" width="24" height="24"> <path fill="currentColor" d="M3 3h18v6H3zm0 8h18v10H3z"/> </svg>`, content: ` <section class="hero" style="padding:96px 24px; text-align:center; background:linear-gradient(135deg,#6366f1,#8b5cf6); color:#fff"> <h1 style="font-size:48px; margin:0 0 16px; font-weight:800">Your headline goes here</h1> <p style="font-size:20px; margin:0 0 32px; opacity:0.9; max-width:600px; margin-inline:auto"> A clear, benefit-driven subheading that explains the value in one breath. </p> <a href="#" style="display:inline-block; padding:14px 28px; background:#fff; color:#6366f1; border-radius:8px; text-decoration:none; font-weight:600"> Get started β†’ </a> </section> ` } } 
// src/builder/blocks/pricing.js export const pricingBlock = { id: 'pricing-table', definition: { label: 'Pricing', category: 'Sections', content: ` <section class="pricing" style="padding:64px 24px; background:#f9fafb"> <div style="max-width:1100px; margin:0 auto; display:grid; grid-template-columns:repeat(3,1fr); gap:24px"> <div style="background:#fff; padding:32px; border-radius:12px; border:1px solid #e5e7eb"> <h3>Starter</h3> <p style="font-size:36px; font-weight:700; margin:8px 0">$9<span style="font-size:16px; color:#6b7280">/mo</span></p> <ul style="padding-left:20px; color:#4b5563"> <li>1 project</li> <li>Basic support</li> <li>Community access</li> </ul> </div> <div style="background:#111827; color:#fff; padding:32px; border-radius:12px"> <h3>Pro</h3> <p style="font-size:36px; font-weight:700; margin:8px 0">$29<span style="font-size:16px; color:#9ca3af">/mo</span></p> <ul style="padding-left:20px"> <li>Unlimited projects</li> <li>Priority support</li> <li>Advanced features</li> </ul> </div> <div style="background:#fff; padding:32px; border-radius:12px; border:1px solid #e5e7eb"> <h3>Enterprise</h3> <p style="font-size:36px; font-weight:700; margin:8px 0">Custom</p> <ul style="padding-left:20px; color:#4b5563"> <li>Custom integrations</li> <li>Dedicated success manager</li> <li>SLA & SSO</li> </ul> </div> </div> </section> ` } } 
// src/builder/blocks/cta.js export const ctaBlock = { id: 'cta-banner', definition: { label: 'CTA', category: 'Sections', content: ` <section style="padding:64px 24px; background:#111827; color:#fff; text-align:center"> <h2 style="font-size:32px; margin:0 0 16px">Ready to ship faster?</h2> <a href="#" style="display:inline-block; padding:14px 28px; background:#6366f1; color:#fff; border-radius:8px; text-decoration:none; font-weight:600"> Start free trial </a> </section> ` } } 
// src/builder/blocks/testimonial.js export const testimonialBlock = { id: 'testimonial-card', definition: { label: 'Testimonial', category: 'Social proof', content: ` <figure style="max-width:640px; margin:48px auto; padding:32px; background:#fff; border:1px solid #e5e7eb; border-radius:12px"> <blockquote style="font-size:20px; line-height:1.5; color:#111827; margin:0 0 16px"> "This tool cut our landing-page turnaround from days to hours." </blockquote> <figcaption style="color:#6b7280">β€” Alex Chen, Head of Growth at ExampleCo</figcaption> </figure> ` } } 

Curating the preset's built-in blocks

The webpage preset registers a lot of blocks by default. If you want a tighter set, pass options when initialising:

// In your grapesjs.init() call plugins: [presetWebpage], pluginsOpts: { [presetWebpage]: { blocksBasicOpts: { blocks: ['column1', 'column2', 'column3', 'text', 'image'], flexGrid: true }, navbar: false, countdown: false, forms: true, exportBtn: false } } 

You can also remove blocks at runtime:

editor.Blocks.remove('quote') editor.Blocks.remove('text-basic') 

Integrating Vue components inside the canvas

This is the question every Vue developer asks within ten minutes of opening GrapesJS:can I drop my<PricingTable>Vue component into the canvas?

Short answer:yes, but not directly.GrapesJS renders inside an<iframe>and outputs plain HTML/CSS β€” it has no awareness of Vue's reactivity system. Three viable approaches, ordered from simplest to most powerful:

Approach 1: Static HTML blocks (recommended for most cases)

If the Vue component is mostly presentational, just put its rendered HTML into a custom block. This is what the section above does. Easy, no runtime overhead, works everywhere.

Approach 2: Vue 3 Custom Elements

Vue 3 can compile any component into a Web Component viadefineCustomElement. Web Components are framework-agnostic, so the GrapesJS iframe can render them as long as their definition script is loaded inside the canvas.

// src/components/PricingTable.ce.js import { defineCustomElement } from 'vue' import PricingTable from './PricingTable.vue' const PricingTableElement = defineCustomElement(PricingTable) customElements.define('pricing-table', PricingTableElement) 

Inject the bundle into the GrapesJS canvas:

editor.Canvas.getDocument().head.insertAdjacentHTML( 'beforeend', '<script type="module" src="/pricing-table.ce.js"><\/script>' ) 

Then your block content can be just<pricing-table plan="pro"></pricing-table>and the browser handles the rest.

Caveats:Custom Elements don't carry Vue's full reactivity into the editor preview (no props panel auto-binding), and styles need to be inlined viastyles: [...]in the component because Shadow DOM blocks the canvas's CSS reset. Use this when the component is genuinely interactive (carousels, accordions, live previews).

Approach 3: Custom component types with traits

GrapesJS components can declaretraitsβ€” editable properties shown in the right-hand panel. Combined with a custom view that mounts a Vue app, you get a properly editable component:

editor.DomComponents.addType('vue-pricing', { isComponent: (el) => el.tagName === 'PRICING-TABLE', model: { defaults: { tagName: 'pricing-table', traits: [ { type: 'select', name: 'plan', options: [ { id: 'starter', name: 'Starter' }, { id: 'pro', name: 'Pro' }, { id: 'enterprise', name: 'Enterprise' } ]}, { type: 'checkbox', name: 'highlighted', label: 'Highlight' } ], attributes: { plan: 'pro' } } } }) 

The user can now select the block, switch between plans in the Trait panel, and the canvas updates live. This is the closest you'll get to a "real" Vue component in the editor.

For most landing-page builders,Approach 1 is what you actually want.Reach for 2 or 3 only when the editing experience needs to be more interactive than dragging static markup.


Saving and loading (local + remote)

This was the section the original 2025 version of this guide promised but never delivered. Here it is.

Local storage (development default)

The configuration we already have saves the entire project tolocalStorageunder the keygjs-project. You can verify it in DevTools β†’ Application β†’ Local Storage.

A project payload looks roughly like:

{ "assets": [], "styles": [...], "pages": [{ "frames": [{ "component": {...} }] }] } 

To inspect or manipulate it manually:

const project = editor.getProjectData() console.log(project) // Apply it back editor.loadProjectData(project) 

Remote storage (production)

For real applications you want the project saved to your backend. The current API usesoptions.remote:

storageManager: { type: 'remote', autosave: true, autoload: true, stepsBeforeSave: 3, // batch writes β€” saves after 3 changes, not every keystroke options: { remote: { urlLoad: `/api/projects/${projectId}`, urlStore: `/api/projects/${projectId}`, // Default is POST for store, GET for load. Override if your API uses PUT/PATCH: fetchOptions: (opts) => opts.method === 'POST' ? { method: 'PUT' } : {}, // Add auth headers: headers: { Authorization: `Bearer ${getAuthToken()}` }, // Shape the outgoing payload: onStore: (data) => ({ id: projectId, data, updatedAt: Date.now() }), // Extract project data from a wrapped response: onLoad: (result) => result.data } } } 

Server-side contract.The simplest backend:

GET /api/projects/:id β†’ 200 { id, data, updatedAt } PUT /api/projects/:id β†’ 200 (body is the wrapped payload from onStore) 

If you'd rather wire it manually β€” for example, to reuse an Axios instance you already configured with CSRF tokens β€” register your own storage instead of using the defaultremote:

import axios from '@/lib/axios' // Inside your useGrapesEditor onReady callback: editor.Storage.add('api', { async load() { const { data } = await axios.get(`/projects/${projectId}`) return data }, async store(payload) { await axios.put(`/projects/${projectId}`, { data: payload }) } }) editor.Storage.setCurrent('api') 

Then setstorageManager.type: 'api'in the init config. This is the cleanest pattern when you're integrating with an existing API client.

Manual save / load with reactive UI

Because we exposedsave(),getProjectData(), andisDirtyfrom the composable, building a "save bar" is trivial:

<template> <div class="save-bar"> <span v-if="isDirty" class="indicator dirty">Unsaved changes</span> <span v-else class="indicator clean">All changes saved</span> <button :disabled="!isDirty" @click="handleSave">Save now</button> <button @click="handleExport">Export HTML</button> </div> </template> <script setup> import { useGrapesEditor } from '@/composables/useGrapesEditor' const { isDirty, save, getHtml, getCss } = useGrapesEditor(/* ... */) async function handleSave() { await save() // returns the stored data; throws if remote fails } function handleExport() { const html = getHtml() const css = getCss() const blob = new Blob([ `<!doctype html><html><head><style>${css}</style></head><body>${html}</body></html>` ], { type: 'text/html' }) const url = URL.createObjectURL(blob) Object.assign(document.createElement('a'), { href: url, download: 'page.html' }).click() URL.revokeObjectURL(url) } </script> 

Pinia integration

If your app uses Pinia, mirror thesummarystate (dirty flag, last-saved timestamp, current device) β€” not the project payload itself. GrapesJS is the source of truth for the canvas:

// src/stores/builder.js import { defineStore } from 'pinia' import { ref } from 'vue' export const useBuilderStore = defineStore('builder', () => { const isDirty = ref(false) const lastSavedAt = ref(null) const currentDevice = ref('Desktop') function markSaved() { isDirty.value = false lastSavedAt.value = new Date() } return { isDirty, lastSavedAt, currentDevice, markSaved } }) 

Sync the store from your composable's event handlers. Don't try to put the GrapesJS instance itself in Pinia β€” same reason we usedshallowRef: it doesn't play well with proxies.


Custom commands and panel buttons

Acommandin GrapesJS is a named action you can trigger from anywhere β€” a panel button, a keyboard shortcut, your own Vue UI, or another command.

Add a "Clear canvas" command

editor.Commands.add('canvas-clear', { run(editor) { if (!confirm('Clear the entire canvas?')) return editor.DomComponents.clear() editor.CssComposer.clear() editor.UndoManager.clear() } }) 

Add a button that triggers it

editor.Panels.addButton('options', { id: 'clear-canvas', className: 'fa fa-trash', command: 'canvas-clear', attributes: { title: 'Clear canvas' } }) 

Trigger commands from Vue

Since the composable exposes the editor:

<template> <button @click="editor?.runCommand('canvas-clear')">Clear</button> <button @click="editor?.runCommand('core:fullscreen')">Fullscreen</button> <button @click="editor?.runCommand('core:preview')">Preview</button> </template> 

Thecore:*commands are built in. Useful ones:

CommandWhat it does
core:undo/core:redoUndo / redo
core:previewToggle preview mode (hides all editor chrome)
core:fullscreenFullscreen the canvas
core:open-codeOpen the code viewer modal
core:component-deleteDelete the selected component
core:copy/core:pasteCopy & paste

Theming the editor UI

GrapesJS classes are prefixed withgjs-. Override them in a stylesheet importedafterthe GrapesJS CSS. A minimal dark theme:

/* src/assets/grapesjs-dark.css */ :root { --gjs-primary-color: #1f2937; --gjs-secondary-color: #f3f4f6; --gjs-tertiary-color: #6366f1; --gjs-quaternary-color: #8b5cf6; } .gjs-one-bg { background-color: #1f2937; } .gjs-two-color { color: #f3f4f6; } .gjs-three-bg { background-color: #6366f1; color: #fff; } .gjs-four-color, .gjs-four-color-h:hover { color: #8b5cf6; } .gjs-pn-panel { background-color: #111827; border-color: #374151; } .gjs-block { background-color: #1f2937; color: #e5e7eb; border-color: #374151; } .gjs-block:hover { border-color: #6366f1; } 

Import order matters:

import 'grapesjs/dist/css/grapes.min.css' import 'grapesjs-preset-webpage/dist/grapesjs-preset-webpage.min.css' import '@/assets/grapesjs-dark.css' // ← after 

Rendering panels into your own Vue layout

For a fully custom shell, tell GrapesJS where each manager should render:

grapesjs.init({ // ... blockManager: { appendTo: '#blocks-panel' }, styleManager: { appendTo: '#styles-panel' }, layerManager: { appendTo: '#layers-panel' }, traitManager: { appendTo: '#traits-panel' }, selectorManager: { appendTo: '#selectors-panel' }, panels: { defaults: [] } // disable default top toolbar β€” you'll build your own }) 

Then in your Vue template:

<template> <div class="builder-grid"> <aside class="left-panel"> <h3>Blocks</h3> <div id="blocks-panel" /> <h3>Layers</h3> <div id="layers-panel" /> </aside> <main ref="editorContainer" class="canvas" /> <aside class="right-panel"> <h3>Styles</h3> <div id="styles-panel" /> <h3>Properties</h3> <div id="traits-panel" /> </aside> </div> </template> 

This is how serious page-builder products integrate GrapesJS β€” the GrapesJSengineruns the canvas while the surrounding UI is 100% Vue.


Responsive design with the Device Manager

Configure the breakpoints you actually care about:

deviceManager: { devices: [ { id: 'desktop', name: 'Desktop', width: '' }, { id: 'tablet', name: 'Tablet', width: '768px', widthMedia: '992px' }, { id: 'mobile', name: 'Mobile', width: '375px', widthMedia: '575px' } ] } 

widthis the canvas viewport when that device is active.widthMediais themax-widthvalue used in the generated CSS media queries.

Switch programmatically:

editor.setDevice('Mobile') editor.on('change:device', () => console.log('Now editing for:', editor.getDevice())) 

Style changes made while a device is active are emitted as media-query-scoped CSS. Inspecteditor.getCss()after editing in Mobile mode β€” you'll see the@media (max-width: 575px) { ... }block.


TypeScript notes

GrapesJS ships its own types since v0.21. The preset plugin doesn't β€” add a shim insrc/shims-grapesjs.d.ts:

declare module 'grapesjs-preset-webpage' { import type { Plugin } from 'grapesjs' const plugin: Plugin export default plugin } 

Composable signature for TS users:

import type { Editor, EditorConfig } from 'grapesjs' import type { Ref, ShallowRef } from 'vue' interface UseGrapesOptions { storage?: EditorConfig['storageManager'] plugins?: EditorConfig['plugins'] pluginsOpts?: EditorConfig['pluginsOpts'] onReady?: (editor: Editor) => void } export function useGrapesEditor( containerRef: Ref<HTMLElement | null>, options: UseGrapesOptions = {} ) { const editor: ShallowRef<Editor | null> = shallowRef(null) // ... rest as before } 

Performance tips for large pages

GrapesJS handles pages of a few hundred components comfortably; past a thousand, you'll feel it. Things that help:

  • Throttlechange:*event listenersβ€” these fire on every keystroke during inline editing. If you're updating a Pinia store on every change,debounceit to 200ms.

  • UsestepsBeforeSave: 3+for remote storage.Saving on every keystroke is brutal for backend and network alike.

  • Avoid mounting many Vue components inside the canvas via Approach 3.Each one carries Vue runtime overhead. Prefer Custom Elements (Approach 2) or static HTML (Approach 1) for repeating blocks.

  • editor.UndoManager.clear()periodicallyif your users do long editing sessions. The undo stack holds full snapshots and can balloon memory.

  • Lazy-load the builder route.GrapesJS + preset bundles to ~400 KB gzipped β€” don't ship it on routes that don't need it:

    // In your router config { path: '/admin/pages/:id', component: () => import('@/views/PageBuilderView.vue') } 

Architecture & maintainability

The patterns that have worked well for us across several production integrations:

  • One composable, one component, one blocks directory.useGrapesEditorfor logic,<PageBuilder>for layout,src/builder/blocks/for content blocks. Don't sprinklegrapesjs.initcalls across the codebase.
  • Treat GrapesJS as a black box.Mirror summary state (dirty, current device, last saved) into Vue/Pinia. Don't mirror the canvas contents β€” that's GrapesJS's job.
  • Destroy on unmount.editor.destroy()inonBeforeUnmount. Skipping this is the #1 cause of memory leaks in SPA integrations.
  • Plugin-ise repeated logic.A GrapesJS plugin is justfunction myPlugin(editor, opts) { ... }. If you find yourself adding the same five blocks and three commands in two places, extract them into a plugin function and pass it viaplugins: [...].
  • Lock the GrapesJS version inpackage.json.Minor versions occasionally break plugin APIs. Pin to an exact version and upgrade deliberately.

Production checklist

Before shipping a GrapesJS-powered builder to users:

  • Remote storage configured with retry / error UX (a failed save shouldn't silently disappear)
  • stepsBeforeSaveβ‰₯ 3 in production
  • Asset Manager pointed at your CDN / S3 (not the default base64 inlining)
  • editor.destroy()on unmount, verified by leaving and returning to the builder route 10 times
  • CSP allowsunsafe-inlinefor the canvas iframe β€” GrapesJS injects styles dynamically
  • Auth tokens injected into theheadersof remote storage (and refreshed before they expire)
  • HTML output sanitised before rendering on the public site (a malicious editor user could inject script tags)
  • Bundle-split the builder route (~400 KB shouldn't be on your homepage)
  • Mobile / Tablet device previews actually tested on real devices, not just the canvas toggle

Conclusion

GrapesJS plus Vue 3 plus Vite is a genuinely productive combination once you settle on the patterns. The key moves:

  1. Scaffold withnpm create vue@latest, not Vue CLI.
  2. Wrap GrapesJS in a composable; expose reactive state to Vue.
  3. UseshallowReffor the editor instance.
  4. Keep blocks in their own modules; register them via a singleregisterCustomBlockscall.
  5. Use the new Storage API (options.remote.*) β€” old top-levelurlStore/urlLoadare deprecated.
  6. Destroy on unmount.
  7. Let Vue own the chrome and GrapesJS own the canvas.

From here you can layer on what your specific product needs: multi-page projects with the Pages module, AI-assisted content generation via the Commands API, role-based locking of components, template galleries, version history. The foundation above scales to all of them.

Happy building.


Appendix: Vue CLI + Webpack compatibility notes

If you're maintaining an existing Vue CLI 5 project and can't migrate to Vite yet, every snippet in this guide works as-is. The only differences:

  • Usevue create grapesjs-vue-appinstead ofnpm create vue@latest.
  • The dev server runs on:8080, not:5173.
  • Path alias@/is configured invue.config.jsinstead ofvite.config.js.
  • GrapesJS plays well with Webpack 5 out of the box; no special loaders needed.
  • For older Vue CLI versions (≀ 4) you may hit issues with the CSS imports β€” upgrade to CLI 5 or migrate to Vite.

For new projects in 2026:use Vite.Vue CLI is in maintenance mode and not receiving new features.


Good luck, and happy building! Your Vue 3 + GrapesJS landing page builder is ready to rock.

πŸ’š Vue.js

Building with GrapesJS + Vue?

Find ready-made Vue-compatible plugins and presets on the marketplace β€” drop them in and skip the boilerplate.

Plugins for your Vue 3 + GrapesJS project

Share this postTwitterFacebookLinkedIn
Published via
DevFuture Development
DevFuture Development
Visit shop β†’

More from DevFuture Development

Discover other insightful posts and stay updated with the latest content.

View all posts

Premium plugins from DevFuture Development

Hand-picked paid additions crafted by this creator.

Visit shop β†’