Direct integration
Import grapesjs, call grapesjs.init() in onMounted(), call editor.destroy() in onUnmounted(). No extra dependency, nothing between you and the editor API.
PageKit — the self-hosted GrapesJS site builder, sold as source. Get early access
Integrate the open-source GrapesJS visual editor into Vue 3 and Nuxt applications to build visual page builders, CMS editors, landing page builders, email editors, and white-label editing experiences.
The split is worth naming before any code. Your Vue application owns routing, users, permissions and data. GrapesJS owns the canvas and everything a person manipulates inside it. The two meet at one DOM node and one save call.
Nothing in that chain requires Vue to know how the editor works, and nothing requires GrapesJS to know it is inside a Vue app. That is why the integration is short.
Yes. GrapesJS is framework-agnostic and can be integrated directly into Vue 3 applications. You can initialize the editor with Vue lifecycle hooks, keep the editor instance in a composable or component, connect GrapesJS events to your Vue application, and persist project data through your own backend.
Import grapesjs, call grapesjs.init() in onMounted(), call editor.destroy() in onUnmounted(). No extra dependency, nothing between you and the editor API.
Wrap that lifecycle in your own composable — or a community package — so components get a Vue-shaped API instead of an imperative one. Useful once more than one screen mounts an editor.
GrapesJS publishes one official framework wrapper, @grapesjs/react, for React. There is no equivalent Vue package from the GrapesJS project, so any Vue wrapper you find on npm is a community project. Check its Vue and GrapesJS peer ranges before you adopt it.
Vue 3 is an application framework. GrapesJS is a visual editing engine. They solve different problems, and the integration is clean precisely because they do not overlap.
Five of these are yours. The rest already exist, and the two remaining are configuration rather than construction.
One dependency. GrapesJS ships its own TypeScript definitions, so there is no separate @types package to add.
npm install grapesjsThe editor is unstyled without it. In a Vite or Nuxt project you can import it from the component or composable that creates the editor; in an app with a global stylesheet, import it once there instead.
Copy this into a component and it runs. Everything after this section is an addition to it, not a rewrite of it.
<template>
<div ref="editorContainer" class="editor-shell"></div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, shallowRef, ref } from 'vue';
import grapesjs, { type Editor } from 'grapesjs';
import 'grapesjs/dist/css/grapes.min.css';
// A template ref: the name matches the ref="" attribute above.
const editorContainer = ref<HTMLDivElement | null>(null);
// shallowRef, not ref — see "Performance" further down the page.
const editor = shallowRef<Editor | null>(null);
onMounted(() => {
if (!editorContainer.value) return;
editor.value = grapesjs.init({
container: editorContainer.value,
height: '100%',
fromElement: false,
storageManager: false,
});
});
onUnmounted(() => {
editor.value?.destroy();
editor.value = null;
});
</script>
<style scoped>
.editor-shell {
height: 100vh;
}
</style>The template ref name matches the ref="" attribute — that is how Vue binds them in <script setup>.
The editor lives in shallowRef, not ref. Vue would otherwise walk the entire editor object graph making it reactive.
fromElement is false, so GrapesJS starts from the components you pass rather than from whatever markup was inside the container.
The whole integration is a lifecycle mapping. Vue tells you when the DOM node exists and when it is about to disappear; GrapesJS needs exactly those two moments.
Read top to bottom, that is the entire contract between the two libraries.
Neither onMounted nor onUnmounted runs on the server, which is why a plain Vue SPA needs nothing extra. A server-rendered app needs one more guard — the Nuxt section covers it.
Once a second screen needs an editor, move the lifecycle into a composable. The component then says what it wants, not how the editor is wired.
// composables/useGrapesJS.ts
import { onMounted, onUnmounted, shallowRef, type Ref } from 'vue';
import grapesjs, { type Editor, type EditorConfig } from 'grapesjs';
import 'grapesjs/dist/css/grapes.min.css';
type EditorOptions = Omit<EditorConfig, 'container'>;
export function useGrapesJS(
container: Ref<HTMLElement | null>,
options: EditorOptions = {},
) {
const editor = shallowRef<Editor | null>(null);
const init = (): Editor | undefined => {
if (editor.value || !container.value) return;
editor.value = grapesjs.init({
container: container.value,
height: '100%',
fromElement: false,
storageManager: false,
...options,
});
return editor.value;
};
const destroy = (): void => {
editor.value?.destroy();
editor.value = null;
};
onMounted(init);
onUnmounted(destroy);
return { editor, init, destroy };
}<template>
<div ref="editorContainer" class="editor-shell"></div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useGrapesJS } from '~/composables/useGrapesJS';
const editorContainer = ref<HTMLElement | null>(null);
const { editor } = useGrapesJS(editorContainer, {
blockManager: {
blocks: [
{
id: 'hero',
label: 'Hero',
category: 'Sections',
content: '<section>…</section>',
},
],
},
});
</script>Resist growing this further than it needs to be. A composable that wraps every GrapesJS module in a reactive facade is a second API to learn and a second thing to keep in sync.
GrapesJS manages editor state. Vue manages application state. Events are the seam: subscribe to the few that your UI actually reflects, and write them into ordinary refs.
import { onMounted, ref, shallowRef } from 'vue';
import type { Editor, Component } from 'grapesjs';
const isReady = ref(false);
const isDirty = ref(false);
const saveState = ref<'idle' | 'saving' | 'saved' | 'error'>('idle');
const selectedName = ref<string | null>(null);
function bindEditorEvents(editor: Editor): void {
editor.on('load', () => {
isReady.value = true;
});
editor.on('component:selected', (component: Component) => {
selectedName.value = component?.getName?.() ?? null;
});
editor.on('component:update', () => {
isDirty.value = true;
});
editor.on('storage:start:store', () => {
saveState.value = 'saving';
});
editor.on('storage:end:store', () => {
saveState.value = 'saved';
isDirty.value = false;
});
editor.on('storage:error', () => {
saveState.value = 'error';
});
}| Event | What your UI does with it |
|---|---|
| load | Hide the loading state once the project is on the canvas |
| component:selected | Show which element is selected in your own toolbar |
| component:update | Mark the document dirty, enable the save button |
| storage:start:store | Show a saving indicator |
| storage:end:store | Show saved, clear the dirty flag |
| storage:error | Surface a failure the user can act on |
Mirror what you display, not what the editor holds. Copying the component tree into a Vue store gives you two sources of truth and a synchronisation bug.
GrapesJS relies on browser DOM APIs, so editor initialization should happen only in a browser context. In Nuxt that means keeping the editor out of the server render — everything else is the same integration you already have.
<!-- pages/editor.vue -->
<template>
<ClientOnly>
<VisualEditor />
<template #fallback>
<p class="editor-placeholder">Loading the editor…</p>
</template>
</ClientOnly>
</template><!-- components/VisualEditor.client.vue -->
<template>
<div ref="editorContainer" class="editor-shell"></div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useGrapesJS } from '~/composables/useGrapesJS';
// Reached only in the browser: the .client suffix keeps this
// component out of the server render, and onMounted never runs
// on the server anyway.
const editorContainer = ref<HTMLElement | null>(null);
const { editor } = useGrapesJS(editorContainer);
</script>Wrap the editor where it is used. The fallback slot gives the server something to render, so there is no blank frame before hydration.
Nuxt's own naming convention: a component whose filename ends in .client is only ever rendered in the browser. Useful when the editor is used in several places.
Both are valid, and they combine. Neither is required by GrapesJS — what GrapesJS requires is simply that init() runs where document exists.
SSR renders the application shell. GrapesJS runs in the browser. Naming that boundary is what makes a Nuxt integration predictable instead of a source of hydration bugs.
The editor screen is not a page you server-render for SEO — it is an authenticated tool. The pages it produces are what you render and index, and those are plain HTML and CSS.
Build a complete Vue page builder →No. Direct integration is a complete, supported way to use GrapesJS, and it is what the examples on this page do. A wrapper is a convenience, not a requirement.
| Approach | Best for | Trade-off |
|---|---|---|
| Direct GrapesJS | Maximum control | You write the lifecycle yourself — about fifteen lines. |
| Vue wrapper / integration | A more Vue-oriented API | A dependency between you and the editor API, on someone else’s release schedule. |
| Custom composable | Reusable application integration | Yours to maintain — but it is the file shown above, and it does not go stale. |
Worth checking before you adopt anything, because the search results for this question are older than the packages they describe.
Checked against the npm registry on 2026-09-02. If you find a newer Vue package, read its peerDependencies first: that single field tells you whether it targets Vue 3 and a current GrapesJS.
For Vue 3 today, the composable above is the wrapper. It is twenty-odd lines, it uses the GrapesJS API directly, and it cannot fall behind a release you depend on.
Mounting the editor is the first afternoon. A page builder your users trust is the rest of the work, and it is mostly configuration of the modules GrapesJS already exposes.
Canvas
The editable surface and its device widths.
Components
The types your users can place, and their traits.
Blocks
The palette they drag from.
Style Manager
Which CSS properties are exposed, and to whom.
Asset Manager
Images and media, from your storage.
Storage Manager
Where project data is loaded and saved.
Commands
Named actions, bindable to your own UI.
Device Manager
Responsive breakpoints for editing and output.
Plugins
Everything above, packaged and reusable.
The same editor core, configured differently. Each of these is a different set of blocks, a different storage target and a different publish step.
Create visual page editing inside your Vue application.
Read moreGive content teams a visual editing interface over the pages your Nuxt site already renders.
Read moreCreate reusable marketing layouts your growth team can assemble without a deploy.
Read moreBuild email templates visually, with output shaped for mail clients rather than browsers.
Read moreEmbed a branded editor into your product, under your own UI and permissions.
Read moreGive users visual control over HTML and CSS, and hand back exactly that.
Read moreThree things that are easy to conflate, and expensive to conflate. Getting this right on day one is what makes a document re-editable a year later.
The editable state of the GrapesJS project — pages, components, styles, assets. Read it with editor.getProjectData(), restore it with editor.loadProjectData(). This is the thing to persist.
The rendered markup, from editor.getHtml(). Regenerate it whenever you publish. It is output, not source.
The styles the editor produced, from editor.getCss(). Same rule: generated, not stored as the source of truth.
Store editable project data. Generate HTML and CSS when you need to render or publish.
Saving only the rendered HTML is the mistake that cannot be undone later. You can always regenerate HTML from project data; you cannot reliably recover project data from HTML.
// Editable state — store this, it is what the editor reloads.
const projectData = editor.getProjectData();
// Rendered output — regenerate this whenever you publish.
const html = editor.getHtml();
const css = editor.getCss();
await fetch(`/api/projects/${projectId}/publish`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ projectData, html, css }),
});
// Sanitize `html` on the server before it is served to anyone.GrapesJS does not care what your backend is. A storage adapter is two async functions — one that returns project data, one that accepts it — so the editor talks to the API you already have.
import grapesjs, { type ProjectData } from 'grapesjs';
// Wherever your app keeps it — a route param, a prop, a store.
const projectId = props.projectId;
const editor = grapesjs.init({
container: editorContainer.value!,
fromElement: false,
storageManager: {
type: 'app-api',
autosave: true,
stepsBeforeSave: 10,
},
// Registered as a plugin so the adapter exists before
// GrapesJS performs its first load.
plugins: [
(ed) => {
ed.Storage.add('app-api', {
async load(): Promise<ProjectData> {
const res = await fetch(`/api/projects/${projectId}`);
if (!res.ok) {
throw new Error(`Load failed: ${res.status}`);
}
return await res.json();
},
async store(data: ProjectData) {
const res = await fetch(`/api/projects/${projectId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ projectData: data }),
});
if (!res.ok) {
throw new Error(`Save failed: ${res.status}`);
}
},
});
},
],
});Registering the adapter through the plugins array matters: it guarantees the storage exists before GrapesJS performs its first load.
The asset manager is a UI over an endpoint. Point it at yours and every existing rule — auth, validation, quotas, CDN — keeps applying.
// Fetched from your own API before the editor is created.
const assets = await fetch('/api/assets').then((res) => res.json());
const editor = grapesjs.init({
container: editorContainer.value!,
assetManager: {
assets,
// Uploads go to your endpoint, with your auth and
// your validation.
upload: '/api/assets',
uploadName: 'files',
multiUpload: true,
autoAdd: true,
headers: { Authorization: `Bearer ${token.value}` },
credentials: 'same-origin',
// Reject what the backend would reject anyway,
// before the round trip.
beforeUpload: (files: File[]) => {
const limit = 5 * 1024 * 1024;
const tooBig = files.some((f) => f.size > limit);
if (tooBig) {
toast.error('Images must be under 5 MB');
return false;
}
},
},
});A custom component type is how the editor learns your product. It gets its own settings panel, its own drop rules and its own block in the palette.
import type { Editor } from 'grapesjs';
export function pricingCardPlugin(editor: Editor): void {
editor.Components.addType('pricing-card', {
isComponent: (el) => el.dataset?.gjsType === 'pricing-card',
model: {
defaults: {
tagName: 'section',
attributes: { 'data-gjs-type': 'pricing-card' },
// Traits become the settings panel for this component.
traits: [
{ type: 'text', name: 'plan', label: 'Plan name' },
{ type: 'text', name: 'price', label: 'Price' },
{ type: 'checkbox', name: 'featured', label: 'Featured' },
],
components: `
<h3 data-gjs-type="text">Starter</h3>
<p data-gjs-type="text">$0 / month</p>
<a href="#">Choose plan</a>
`,
},
},
});
editor.Blocks.add('pricing-card', {
label: 'Pricing card',
category: 'Marketing',
content: { type: 'pricing-card' },
});
}
// Pass it to the editor like any other plugin:
// grapesjs.init({ container, plugins: [pricingCardPlugin] })Custom components are also how you constrain the editor. Setting droppable or draggable on a type is what stops a user dropping a pricing card inside a navigation bar, and traits are what give them a labelled field instead of a raw CSS control.
These are GrapesJS component types, not Vue components. The canvas renders DOM; your Vue components render your application around it.
A plugin is a function that receives the editor — the same function signature whether it came from npm, from GJS.Market, or from your own repository. None of them care that the host application is Vue.
import grapesjs from 'grapesjs';
import basicBlocks from 'grapesjs-blocks-basic';
import { pricingCardPlugin } from '~/editor/pricing-card';
const editor = grapesjs.init({
container: editorContainer.value!,
plugins: [
// Options go through a wrapper function: `pluginsOpts`
// is keyed by string, so it only reaches plugins that
// were passed to `plugins` by name.
(ed) => basicBlocks(ed, { flexGrid: true }),
pricingCardPlugin,
],
});The palette, the text editing and the form controls that make the canvas useful on day one.
Browse categoryA starting palette so the canvas is not empty the first time a user opens it.
Tailwind-shaped sections, for teams whose output already uses Tailwind.
A richer inline text toolbar than the built-in one.
Form fields as editable components, with the trait panel for their attributes.
Persistence, autosave and crash recovery — the part every prototype postpones.
Browse categoryMultiple projects, listed and switchable, without writing the UI for it.
Local persistence — useful for drafts, offline work and a fast prototype.
Recovers unsaved work after a crash or a closed tab.
A storage adapter for teams already running Directus as their backend.
Media handling and the step between a saved project and a served page.
Browse categoryUploads and transformations through Cloudinary instead of your own pipeline.
Uppy as the upload UI, with its resumable and multi-source handling.
Hands the finished page back as a downloadable archive.
Flags accessibility and SEO problems before a page is published.
Not an argument that building is wrong — an inventory of what building means. Every row is a subsystem that exists whether or not you planned for it.
| Capability | Build yourself | GrapesJS |
|---|---|---|
| Canvas | Build | Included |
| Drag & drop | Build | Included |
| Components | Build | Included |
| Blocks | Build | Included |
| Style Manager | Build | Included |
| Asset Manager | Build | Extensible |
| Storage Manager | Build | Extensible |
| Plugins | Build an ecosystem | Plugin architecture |
| Vue integration | Native | Framework-agnostic |
Vue gives you the application framework. GrapesJS gives you the visual editing engine.
Because Vue components are application building blocks, and a visual editor needs something else on top of them: canvas management, a component tree with drop rules, selection and hover state, a style layer that writes real CSS, device switching, undo and redo across all of it, asset management, serialization, named commands, and an extension point for the whole thing. You can build that in Vue — it is simply a different project from the one you set out to do.
Vue + GrapesJS is a different proposition from Vue components alone.
Your Nuxt or Vue application: the shell, the session, and who is allowed to open which project.
The editor, mounted on one screen. It receives a project and emits a project.
Your own routes. GrapesJS calls them through the storage and asset adapters you wrote.
Where projects, media, users and versions actually live — unchanged by the editor above it.
The editor is a leaf in that tree, not the root. Everything above it is code you would have written anyway.
Do not make the editor deeply reactive
This is the one that actually bites. shallowRef, or a plain module-scoped variable — never ref() around the editor instance. Vue would walk a very large object graph and re-run effects on internal churn.
Initialize only when needed
Create the editor on the screen that edits, not in a layout or a store that every route mounts.
Lazy-load the editor
GrapesJS is a substantial bundle. Load it in its own async chunk so routes that never edit never pay for it.
Keep updates out of global state
Pushing every component:update into a store turns each keystroke into an application-wide render.
Paginate large asset collections
Seed the asset manager from a paged endpoint rather than shipping thousands of URLs into the editor.
Keep component trees manageable
Very deep documents cost more to render, serialize and diff. Structure encourages itself if your blocks do.
A visual editor is a system that accepts user-authored HTML. Treat it that way.
Almost every integration issue reported for this combination is one of these seven.
Symptom
The container is null, or the editor renders into nothing.
Fix
Create the editor inside onMounted(). The template ref has no element before then.
Symptom
Memory grows and stale listeners fire after leaving the route.
Fix
Call editor.destroy() in onUnmounted() and clear the reference.
Symptom
The editor feels sluggish; Vue devtools stalls.
Fix
Hold the instance in shallowRef, or outside Vue reactivity entirely.
Symptom
document is not defined, or a hydration mismatch on the editor route.
Fix
Render the editor client-only — <ClientOnly>, a .client component, or both.
Symptom
Two sources of truth, and edits that disappear on navigation.
Fix
Let GrapesJS own the project; mirror only the few values your UI displays.
Symptom
The editor loads, but the panels are unstyled and unusable.
Fix
Import grapesjs/dist/css/grapes.min.css, or include it in your global stylesheet.
Symptom
Type errors, or an integration built on Vue 2 lifecycle hooks.
Fix
Check peerDependencies before adopting any wrapper. Several popular examples predate Vue 3.
On Vue 2 the same integration uses mounted() and beforeDestroy() instead of onMounted() and onUnmounted(); the GrapesJS side is unchanged. New work should target Vue 3 — everything on this page assumes it.
The order roughly matches the order this page introduced them.
Yes. GrapesJS is a framework-agnostic library that renders into a DOM element, so it works in Vue 3 without an adapter. Initialize it in onMounted() and destroy it in onUnmounted().
Run npm install grapesjs. The package includes its own TypeScript definitions. You also need to load its stylesheet, either by importing grapesjs/dist/css/grapes.min.css or by including it in your global CSS.
Create a template ref on the container element, then call grapesjs.init({ container: containerRef.value }) inside onMounted(). The element does not exist before mount, so initializing any earlier gives GrapesJS nothing to render into.
Yes, and it is the recommended way. onMounted() and onUnmounted() map directly onto grapesjs.init() and editor.destroy(), and the whole integration fits in a single composable.
Yes. Every example on this page uses <script setup lang="ts">. A template ref declared with ref() binds automatically to the matching ref="" attribute.
Yes. The integration is identical to plain Vue 3; the only addition is keeping the editor out of the server render, with <ClientOnly> or a .client component.
Yes, with the editor rendered client-only. GrapesJS needs browser DOM APIs, so it cannot run during a server render — but the pages it produces are plain HTML and CSS, and those are exactly what you server-render and index.
No. Direct integration is a complete approach and is what the examples here use. GrapesJS publishes an official React wrapper but no Vue equivalent, so any Vue wrapper on npm is a community project — check its Vue and GrapesJS peer ranges before adopting it.
Yes. GrapesJS ships TypeScript definitions in the package, so the Editor type and the configuration object are typed without an extra @types dependency.
Yes. Register a storage adapter with editor.Storage.add() that implements load() and store(). Both are ordinary async functions, so they can call whatever API you already have.
Yes. editor.Components.addType() defines a component type with its own traits and drop rules, and editor.Blocks.add() puts it in the palette. That is how the editor learns your product’s vocabulary.
Yes. A GrapesJS plugin is a function that receives the editor instance, so it is unaffected by the framework hosting it. Pass plugins in the plugins array of grapesjs.init().
Yes — that is the common case. Mounting the editor is the short part; the rest is configuring blocks, storage, assets and permissions around it.
Integrate the visual editing engine into your Vue or Nuxt application, keep control of your data and output, and extend the editor with the plugins your product needs.
Build your application with Vue 3. Add visual editing with GrapesJS.