ArtisanPack UI
DocsVisual EditorInertia embedding recipes

Inertia embedding recipes

Visual Editor v1.18.0

The visual editor is a React app under the hood, so embedding inside Inertia is just a matter of mounting the Blade component on the Inertia page and...

The visual editor is a React app under the hood, so embedding inside Inertia is just a matter of mounting the Blade component on the Inertia page and letting the React tree boot inside it. The same recipe works inside Inertia + React (direct) and Inertia + Vue (via the Vue wrapper).

Both recipes assume the editor is being mounted inside an Inertia page that the host app owns — the host controls auth, navigation, and non-editor chrome; the editor takes over the canvas region.


1. Inertia + React

The Blade <x-visual-editor /> mount works inside an Inertia-rendered template because Inertia hydrates inside an outer Blade layout. Mount the editor as a top-level page or inside a content slot, and import the editor's React bundle from the host's entrypoint.

Server-side: the Inertia page

// app/Http/Controllers/PostEditorController.php
public function edit(Post $post)
{
    return Inertia::render('Posts/Edit', [
        'post' => [
            'id'             => $post->id,
            'title'          => $post->title,
            'slug'           => $post->slug,
            'status'         => $post->status,
            'previewUrl'     => route('posts.show', $post),
        ],
    ]);
}

Client-side: the React page

// resources/js/Pages/Posts/Edit.tsx
import { useEffect, useRef } from 'react';
import { mountEditor } from '@artisanpack-ui/visual-editor';

export default function PostEditor({ post }) {
    const mountRef = useRef<HTMLDivElement>(null);

    useEffect(() => {
        if (!mountRef.current) return;

        const editor = mountEditor(mountRef.current, {
            apiBase: '/visual-editor/api',
            resource: 'posts',
            id: String(post.id),
            initialTitle: post.title,
            initialSlug: post.slug,
            initialStatus: post.status,
            previewUrl: post.previewUrl,
            supports: { excerpt: true, featuredImage: true },
        });

        return () => editor.unmount();
    }, [post.id]);

    return (
        <Layout>
            <div ref={mountRef} className="ap-visual-editor" />
        </Layout>
    );
}

mountEditor(element, config) is the imperative equivalent of the Blade component. It takes the same config keys in camelCase (apiBase, resource and id are required) and returns a MountedEditor handle:

Member Description
unmount() Unmounts the React root and frees the element. Safe to call more than once.
ready A promise that resolves once the editor bundle has loaded and the first render is scheduled. It rejects if the bundle fails to load.

Mounting twice on the same element (React StrictMode, HMR) is a no-op that returns a handle for the existing editor.

Bridging editor events

The editor dispatches ve:editor:change, ve:editor:autosave and ve:editor:save on window. Each detail carries resource, id and the current blocks; autosave and save also carry updatedAt. The event names are exported as VE_EDITOR_CHANGE, VE_EDITOR_AUTOSAVE and VE_EDITOR_SAVE. Filter on resource and id when a page mounts more than one editor:

import { VE_EDITOR_SAVE, type VeEditorSaveEvent } from '@artisanpack-ui/visual-editor';

useEffect(() => {
    const onSave = (event: VeEditorSaveEvent) => {
        if (event.detail.resource !== 'posts') return;
        if (event.detail.id !== String(post.id)) return;
        // Sync Inertia state, show a toast, etc.
    };

    window.addEventListener(VE_EDITOR_SAVE, onSave as EventListener);
    return () => window.removeEventListener(VE_EDITOR_SAVE, onSave as EventListener);
}, [post.id]);

Inertia navigation

Inertia has no wire:ignore. When you navigate between Inertia pages, the React tree unmounts entirely, and the cleanup function in the useEffect above unmounts the editor.

The handle has no dirty flag. To warn before leaving with unsaved edits, track it from the events: a ve:editor:change marks the page dirty, and a ve:editor:save or ve:editor:autosave marks it clean.

import { router } from '@inertiajs/react';
import { VE_EDITOR_AUTOSAVE, VE_EDITOR_CHANGE, VE_EDITOR_SAVE } from '@artisanpack-ui/visual-editor';

const dirty = useRef(false);

useEffect(() => {
    const isSelf = (event: Event) => {
        const detail = (event as CustomEvent<{ resource: string; id: string }>).detail;
        return detail?.resource === 'posts' && detail?.id === String(post.id);
    };
    const markDirty = (event: Event) => { if (isSelf(event)) dirty.current = true; };
    const markClean = (event: Event) => { if (isSelf(event)) dirty.current = false; };

    window.addEventListener(VE_EDITOR_CHANGE, markDirty);
    window.addEventListener(VE_EDITOR_AUTOSAVE, markClean);
    window.addEventListener(VE_EDITOR_SAVE, markClean);

    return () => {
        window.removeEventListener(VE_EDITOR_CHANGE, markDirty);
        window.removeEventListener(VE_EDITOR_AUTOSAVE, markClean);
        window.removeEventListener(VE_EDITOR_SAVE, markClean);
    };
}, [post.id]);

const handleNavigate = (href: string) => {
    if (dirty.current && !confirm('You have unsaved changes. Leave?')) return;
    router.visit(href);
};

2. Inertia + Vue

The Vue wrapper at @artisanpack-ui/visual-editor/vue exports a <VisualEditor> Vue component that wraps the React editor in a Vue tree. The editor itself stays React; Vue manages mount, unmount and prop updates.

Server-side: same Inertia page

The controller is identical to the React recipe. Inertia doesn't care which client renderer the page uses.

Client-side: the Vue page

<!-- resources/js/Pages/Posts/Edit.vue -->
<script setup lang="ts">
import { VisualEditor } from '@artisanpack-ui/visual-editor/vue';

const props = defineProps<{
    post: {
        id: number;
        title: string;
        slug: string;
        status: string;
        previewUrl: string;
    };
}>();

const onSaved = (detail) => {
    // Sync Inertia state, show a toast, etc.
};

const onChanged = (detail) => {
    // Edits in flight: mark the page dirty, defer navigation, etc.
};
</script>

<template>
    <Layout>
        <VisualEditor
            api-base="/visual-editor/api"
            resource="posts"
            :model="{
                id: post.id,
                title: post.title,
                slug: post.slug,
                status: post.status,
            }"
            :preview-url="post.previewUrl"
            :supports="{ excerpt: true, featuredImage: true }"
            @saved="onSaved"
            @autosaved="onSaved"
            @changed="onChanged"
        />
    </Layout>
</template>
Prop Required Description
model yes The record: id plus any of title, slug, status, excerpt, featuredImage, authorId, commentsOpen. They become the editor's initial* config.
api-base yes The editor API base, e.g. /visual-editor/api.
resource yes The resource key, e.g. posts.
preview-url no The Preview button's URL.
author-options no Authors for the author picker.
supports no Which document panels to show.
can-edit-block-css, custom-css-allow-urls, block-styles no Since v1.15.0. See below.

The component listens for the ve:editor:* browser events, keeps only the ones for its own resource and model.id, and re-emits them as Vue events: changed, autosaved and saved, each with the event's detail.

Vue → React data flow

Changing resource, model.id or api-base after mount unmounts the editor and mounts a fresh one for the new record. The other props are read at mount time. To apply new values, re-key the component (:key="post.id").

Custom CSS access and theme block styles

Since v1.15.0. The Blade <x-visual-editor> mount works out the Custom CSS permission and the theme's block style variations itself. An Inertia host has to pass them in, or the editor fails closed: the Custom CSS field stays hidden and the Styles picker shows no theme variations.

Mount config key (React) Vue prop Default Value to pass
canEditBlockCss can-edit-block-css false (field hidden) The visual-editor.edit-block-css gate result: Gate::allows( BlockCssAccess::ABILITY ).
customCssAllowUrls custom-css-allow-urls true Config effects.custom_css_allow_urls, so the canvas previews what the front end prints.
blockStyles block-styles — app( BlockStyleResolver::class )->editorPayload(): the theme's block style variations for the Styles picker.
use ArtisanPackUI\VisualEditor\BlockStyles\BlockStyleResolver;
use ArtisanPackUI\VisualEditor\Effects\BlockCssAccess;
use Illuminate\Support\Facades\Gate;

return Inertia::render('Posts/Edit', [
    'post'               => [ /* … */ ],
    'canEditBlockCss'    => Gate::allows( BlockCssAccess::ABILITY ),
    'customCssAllowUrls' => (bool) config( 'artisanpack.visual-editor.effects.custom_css_allow_urls', true ),
    'blockStyles'        => app( BlockStyleResolver::class )->editorPayload(),
]);
<VisualEditor
    resource="posts"
    :can-edit-block-css="canEditBlockCss"
    :custom-css-allow-urls="customCssAllowUrls"
    :block-styles="blockStyles"
    …
/>

canEditBlockCss only shows or hides the field. Every save re-checks the gate on the server, so a client that sets it to true without the ability still can't store Custom CSS. See Access gate for how the gate is decided.


3. Front-end rendering

The Inertia + React / Inertia + Vue recipes above are for the edit surface. Rendering saved content on the public site uses the renderer packages:

  • React: @artisanpack-ui/visual-editor-renderer-react
  • Vue: @artisanpack-ui/visual-editor-renderer-vue
// resources/js/Pages/Posts/Show.tsx
import { BlockTree, GlobalStyles } from '@artisanpack-ui/visual-editor-renderer-react';

export default function PostShow({ post }) {
    return (
        <>
            <GlobalStyles />
            <BlockTree tree={post.blocks} />
        </>
    );
}

Since v1.15.0, pass the theme's duotone presets (duotonePresets) and the effects.custom_css_allow_urls setting (customCssAllowUrls) to <BlockTree>. If the host customizes breakpoints, also call setBreakpoints() with the server's BreakpointRegistry::toArray() once at bootstrap. See Styles <BlockTree> prints and Breakpoints.

See Renderers for the full client renderer API.


4. SSR

The editor itself is client-only (it relies on window and DOM APIs). Inertia SSR will skip the editor's render pass — wrap the mount in a <ClientOnly> (Inertia + Vue) or guard the useEffect (Inertia + React) so server rendering produces an empty mount point.

Front-end renderers work in SSR — the React renderer renders to static HTML, the Vue renderer renders to static markup. Use the Blade renderer when you want SSR via Laravel itself (Inertia or not).


5. Authentication

The editor's API calls inherit the host page's session cookies — no extra wiring needed when Inertia uses the standard web middleware. For Sanctum / API-token auth, override the API middleware:

// config/artisanpack/visual-editor.php
'api' => [
    'middleware' => ['api', 'auth:sanctum'],
],

See also