Integration guide

Everything a consuming site needs, in the order it needs it. The one step that silently breaks the UI is the Tailwind @source line — it has its own section below.

Install

Three packages, published public under AGPL-3.0-only. The Nuxt module depends on the Vue package, which depends on the core — installing all three explicitly keeps the versions visible in your manifest.

nuxt app
pnpm add @nextmoe/edit-ui-nuxt @nextmoe/edit-ui-vue @nextmoe/edit-ui-core
pnpm add @kungal/ui-nuxt @kungal/ui-tokens @kungal/ui-vue
pnpm add -D tailwindcss @tailwindcss/vite @iconify-json/lucide
vue + vite app
pnpm add @nextmoe/edit-ui-vue @nextmoe/edit-ui-core
pnpm add @kungal/ui-vue @kungal/ui-tokens
pnpm add @iconify/vue          # see "Icons" under Path A
pnpm add -D tailwindcss @tailwindcss/vite
PackagePeer dependenciesNotes
@nextmoe/edit-ui-corenoneBundles diff ^9. No Vue anywhere in its dependency closure.
@nextmoe/edit-ui-vuevue ^3.5.0 · @kungal/ui-vue ^2Also pulls @vueuse/core, @vueuse/integrations and sortablejs as normal dependencies.
@nextmoe/edit-ui-nuxtvue ^3.5.0 · nuxt ^4.0.0 · @kungal/ui-vue ^2Ships raw TS loaded by Nuxt jiti — no build output.

Path B — Nuxt

The module registers all 14 components as auto-imports, so templates need no import at all.

nuxt.config.ts
// nuxt.config.ts
export default defineNuxtConfig({
  // KunUI's Nuxt layer: auto-imports every Kun* component and injects
  // NuxtLink / @nuxt/icon / @nuxt/image into KunUI's config.
  extends: ['@kungal/ui-nuxt'],

  modules: ['@nextmoe/edit-ui-nuxt'],

  css: ['~/assets/css/main.css'],

  editUi: {
    prefix: 'Edit', // default. <SchemaForm> registers as <EditSchemaForm>
    global: false   // default: lazy registration
  }
})
pages/edit.vue
<template>
  <EditSchemaForm
    :fields="fields"
    :values="values"
    :config="config"
    :group-order="['Basics', 'Media']"
    layout="tabs"
    @update:patch="(patch) => (draft = patch)"
  />

  <EditFieldDiff label="Title" diff-hint="inline" :from="a" :to="b" />
  <EditReviewQueue v-model:status="status" :items="open" :label-for="labelFor" />
</template>

The prefix is why the module exists: a site migrating off its own local copy of these components can set prefix: 'Editkit' and keep every existing <EditkitSchemaForm> tag untouched. The component names come from EDIT_UI_COMPONENT_NAMES in @nextmoe/edit-ui-vue, so the module never drifts from the package.

Path A — Vue 3 + Vite

Components are exported under their bare names; the Edit prefix is applied only by the Nuxt module.

main.ts
// main.ts
import { createApp } from 'vue'
import { installKunUIConfig } from '@kungal/ui-vue'
import { Icon } from '@iconify/vue'
import { RouterLink } from 'vue-router'
import App from './App.vue'
import './style.css'

const app = createApp(App)

installKunUIConfig(app, {
  iconComponent: Icon,      // see the note below — without this some icons vanish
  linkComponent: RouterLink // optional: keeps KunUI hrefs in the SPA router
})

app.mount('#app')
Editor.vue
<script setup lang="ts">
import { SchemaForm, FieldDiff } from '@nextmoe/edit-ui-vue'
import type { EditFieldConfigMap, EditSchemaField } from '@nextmoe/edit-ui-vue'

const fields = ref<EditSchemaField[]>([])
const config: EditFieldConfigMap = { title: { label: 'Title', control: 'input' } }
</script>

<template>
  <SchemaForm :fields="fields" :values="values" :config="config" />
</template>

Icons: KunUI only inlines its bundled registry

KunIcon renders icons from KunUI's own bundled set as inline SVG and never fetches anything. A name outside that set — lucide:undo-2 on the field revert button, for one — renders nothing at all unless you inject an iconComponent through installKunUIConfig. The Nuxt layer does this for you with @nuxt/icon; a plain Vue app must do it itself.

The Tailwind v4 @source line

The single step that costs people an afternoon.

Miss this and the UI degrades with no error at all

These packages ship no CSS. Every class in them is a KunUI utility, and Tailwind v4 only emits the utilities it finds in the files it scans — node_modules is not scanned by default. Without the @source line below, the classes the components use are simply never generated: no build error, no console warning, just a form with collapsed spacing, no borders and no colour.

nuxt
/* app/assets/css/main.css — paths are relative to THIS file */
@import 'tailwindcss';
@import '@kungal/ui-tokens';
@import '@kungal/ui-vue/style.css';

@source '../../../node_modules/@kungal/ui-vue';
@source '../../../node_modules/@nextmoe/edit-ui-vue/dist';
vue + vite
/* src/style.css — one directory below node_modules */
@import 'tailwindcss';
@import '@kungal/ui-tokens';
@import '@kungal/ui-vue/style.css';

@source '../node_modules/@kungal/ui-vue';
@source '../node_modules/@nextmoe/edit-ui-vue/dist';
  • Point at /dist, not at the package root: dist is the only directory the package publishes, and narrowing the scan keeps the emitted CSS to the classes really used.
  • Relative to the CSS file, not to the project root. A Nuxt entry at app/assets/css/main.css needs three ../ to reach node_modules; a Vite entry at src/style.css needs one.
  • Both directories are usually git-ignored and Tailwind scans them anyway — an explicit @source is not subject to the automatic content-detection rules. If your classes are still missing, check the path itself first.
  • KunUI needs the same treatment — the second @source line above is not optional either, and @kungal/ui-tokens is what defines the palette these classes resolve against.
  • Dark mode is a .kun-dark-mode class on <html> — KunUI's own switch. The components never write a dark: prefix; they use semantic tokens that already flip.

The two inputs: schema and config

The schema comes from your server and says what may be edited. The config is yours and says how it looks. Nothing in the packages knows about either your API or your images.

from your API
// What your API sends per field — @nextmoe/edit-ui-core
interface EditSchemaField {
  key: string
  kind: string          // text | int | enum | bool | date | imagehash | list | ref
  diff_hint: string     // inline | lines | items | image
  deprecated?: boolean
  locked: boolean
  can_propose: boolean
  can_review: boolean
  would_automerge: boolean
}
SchemaForm
<EditSchemaForm
  :fields="fields"
  :values="values"
  :config="config"
  :group-order="['Basics', 'Media']"
  :tabbed-groups="['Relations']"
  layout="tabs"
  :disabled="isReviewer"
  @update:patch="onPatch"
/>
fieldsEditSchemaField[]Required. The schema your server sent.
valuesRecord<string, unknown>Required. The current server values, used as the diff baseline.
configEditFieldConfigMapRequired. Labels, controls, groups, injected functions.
group-orderstring[]Section order. Groups you omit keep their natural order after it.
tabbed-groupsstring[]These groups render their fields as inner tabs instead of stacking.
layout'stack' | 'tabs'Defaults to 'stack'. 'tabs' puts the group list beside the fields.
disabledbooleanRenders every control read-only — the review view of the same form.
@update:patchRecord<string, unknown>Emitted on every edit: the changed fields only, deep-compared against values. Locked, deprecated and un-proposable fields never appear.

Controls a field config may ask for

inputnumbertextareaselectswitchdatestring-listnumber-listobject-listentity-pickerentity-kind-pickerimageimage-listreadonly

Omit control and it is derived from the schema's kind + diff_hint by resolveControl().

Anything that touches a backend is a function you supply

EditFieldConfig keySignatureYour job
uploadImage(file, currentItems) => Promise<item | null>Upload one picture and return the item to store. Absent ⇒ the image field renders read-only.
resolveImage(value) => stringTurn a stored image value (a hash, a row, a URL) into something an <img> can show.
searchEntities(keyword) => Promise<EditSelectOption[]>Search your catalogue for the entity pickers. Absent ⇒ the field does not render as a picker.
resolveEntities(ids) => EditSelectOption[] | Promise<…>Turn already-stored ids back into labels on first render.

There is no default, no fallback fetch and no built-in endpoint.

What is in the box

Fourteen exports from @nextmoe/edit-ui-vue. Under the Nuxt module each one gains the configured prefix.

SchemaFormThe whole form: groups → sections (stack or tabs), emits update:patch.
SchemaFieldOne field; picks the control from the schema + config.
ObjectListFieldRepeating rows of typed columns.
ImageFieldSingle or multi image upload, drag-sort, pin flag.
EntityPickerSearch-and-pick entity references.
EntityKindPickerEntity references that also carry a kind.
SourceContextThe read-only "this came from upstream" strip under a field.
FieldDiffOne field's before/after, routed by diff_hint.
TextDiffWord-level text diff, long unchanged runs elided.
ImageDiffOnly the pictures that changed, never the whole gallery.
ProposalCardOne edit proposal.
ReviewQueueProposals by status, with an item slot.
RevisionTimelineRevisions, two-way selection, emits diff(fromSeq, toSeq).
TimeMinimal relative/absolute timestamp used by the two views above.

Unknown controls degrade, never throw

Your edit engine may start sending a control (or a schema kind) that the installed package predates. SchemaField renders such a field as a read-only text/JSON display rather than throwing, or falling through to a text input that would invite an edit you cannot save. isEditControl() is the check; a spec pins the behaviour.

Built-in strings are zh-CN today

A handful of strings live inside the components — 已修改 / 撤销 on a dirty field, the ReviewQueue status tabs, the relative times. There is no locale option yet. Everything else (field labels, group names, option labels) comes from the config you pass, so it is already in your language.

Still stuck?

The repository's playground app is this very site — it consumes the packages the way your app will, so its source is a working reference.