⚠️ Work in progress. This package is under active development and pre-1.0 — the API may change and breaking changes can land in any release. Not recommended for production yet. See the Status section for what works today.
Inline CMS for Nuxt 4 on top of @nuxt/content. An alternative to nuxt-studio: edit content directly on the page — hover an element, click, change it. No separate panel.
The schema is one declarative object next to the markup. defineOne returns a value/component pair for every key: lowercase title is the raw reactive value, Capitalized Title is the editable component. The document id is derived from the file path, so you never write it by hand.
<!-- app/components/homepage/Hero.vue → id "homepage.hero" -->
<script setup lang="ts">
const { title, Title, Subtitle, gender, Gender } = defineOne({
title: {
type: 'text',
default: 'Edit me in place',
},
subtitle: {
type: 'textarea',
},
gender: {
type: 'enum',
options: [
{ title: 'Male', value: 'male' },
{ title: 'Female', value: 'female' },
],
},
})
// lowercase keys are the raw reactive values, for logic rather than rendering
const isMale = computed(() => gender.value === 'male')
</script>
<template>
<Title as="h1" class="text-4xl font-bold" />
<Subtitle as="p" />
</template>A top-level field renders as its own component (<Title/>). The data lives in content/one/en/homepage.hero.yml and is committed to git.
pnpm add nuxt-one @nuxt/content @nuxt/ui// nuxt.config.ts
export default defineNuxtConfig({
modules: ['nuxt-one'],
})nuxt-one installs @nuxt/content and @nuxt/ui for you — list only nuxt-one in modules. Add them yourself only if you need to configure one; nuxt-one detects that and won't install it twice.
That's it — nothing to add to app.vue. In nuxt dev the editor mounts itself into an isolated shadow DOM (like devtools), so it never touches your styles or markup, and it's stripped from production entirely.
@nuxt/content needs the native better-sqlite3. By default pnpm 11 does not run its build (ERR_PNPM_IGNORED_BUILDS) — allow it in pnpm-workspace.yaml:
allowBuilds:
better-sqlite3: true| You write | You get |
|---|---|
defineOne(schema) |
value/component pairs, one per key |
defineOneMeta(schema) |
SEO/meta fields, merged across the whole site |
defineOneOptions({ ... }) |
per-component config (reserved for isolated, section) |
useOneMeta() |
the merged meta values, for useSeoMeta |
<One :field="…"> |
renders a sub-field ref from a group/repeat slot |
OneType |
string enum for type (OneType.Text === 'text') |
The single entry point. It is called in <script setup> and returns, for every schema key k:
| Return | What it is |
|---|---|
k (lowercase) |
Ref of the raw value — for logic and {{ k }} |
K (Capitalized) |
the editable component — <K as="h1" /> |
<script setup lang="ts">
const { title, Title } = defineOne({
title: { type: 'text' },
})
</script>
<template>
<!-- editable, tagged for the overlay in dev -->
<Title as="h1" class="text-4xl" />
<!-- raw value, not editable -->
<h1>{{ title }}</h1>
</template>The DOM is identical in dev and production; only the data-one-field attribute (added in dev) differs. Keys are explicit in the schema object, so renaming the destructured variable never moves the stored key.
A schema entry is either a scalar (has a type), a group (a plain object of entries, no type), or a repeat (a list — see below).
{ type: 'text', label: '', placeholder: '', default: '', required: false }
{ type: 'textarea' } // like text, but a multi-line editor
{ type: 'enum', options: [{ title, value }] } // → a union of literals in the value type
{ title: { type: 'text' }, href: { type: 'text' } } // a group — no `type` of its owntype accepts a string or an OneType member — type: 'text' and type: OneType.Text are the same.
Labels are optional. By default the field name is humanized for the editor UI (ctaLabel → "Cta Label"). Pass label only to override it.
enum infers a union: with those options, gender.value has the type 'male' | 'female', not string.
A repeatable list is a type: 'repeat' entry whose per-item schema lives under item:
const { services, Services } = defineOne({
services: {
type: 'repeat',
item: {
title: { type: 'text' },
description: { type: 'textarea' },
},
},
})Services is a repeat component: it does its own v-for and tags each row with data-one-item (so the overlay's add/remove/reorder toolbar knows the boundaries). Use it directly — no wrapper. The slot hands you each item's fields both as lowercase values and as Capitalized refs for <One :field>, plus index:
<Services v-slot="{ Title, Description, index }">
<article>
<One :field="Title" as="h3" />
<One :field="Description" as="p" />
</article>
</Services>Sub-fields go through <One :field="Title"> rather than a bare <Title/>: Vue resolves a capitalized tag as a global component at compile time, before the slot scope exists, so a slot-provided tag never resolves. <One :field> renders the ref instead.
A group is a nested object with no type. Its component makes the whole block hoverable — edited as a single unit, not field by field. The slot hands you the child values and refs:
<Action v-slot="{ label, href, Label }">
<a :href="`${href}`">{{ label }}</a>
</Action>{{ label }} prints the value; wrap Label in <One :field="Label"> to make that field individually editable instead.
Declared next to defineOne, in the same component. Its values are collected into a global registry and merged across every component that declares meta. The values are stored under the meta key of that document's own YAML — a shared file would be a write-conflict point.
// app/layouts/default.vue — site-wide defaults
defineOneMeta({ title: { type: 'text' }, description: { type: 'textarea' } })
// app/pages/about.vue — overrides the layout, because the page mounts later
defineOneMeta({ title: { type: 'text' } })Read the merged result once, wherever you set the head:
// app/app.vue
const meta = useOneMeta() // merge across all documents
useSeoMeta({ title: () => meta.value.title })On a key conflict, the last-declared document wins (a page beats its layout). Meta is never rendered on the page — you edit it through the Meta button in the editor navbar.
A build-time plugin makes the file-path id and the schema available without running any component:
- finds
defineOne/defineOneMeta/defineOneOptionsin.vuefiles, derives theidfrom the path, and injects it as the first argument; - extracts the schema object into a separate module along with the imports it references;
- executes that module through vite's
runnerImport— outside the Vue runtime — and compiles the plain schema object into field descriptors.
From the descriptors it builds a registry: the types, the field editors in the overlay, and the Valibot validation schema are all generated from it.
Document values are inlined into the bundle at build time: they are small singletons, read on almost every page, and a queryCollection for each would be wasteful.
Active only under nuxt dev. Field components tag their element with data-one-field (or data-one-group / data-one-item); the overlay catches hover through a delegated listener, highlights the element on a separate layer (without touching its layout), and opens a popover with the editor chosen by field type.
Groups are edited as a whole. A group block opens a popover with every field of the group — so the values share their context — while focus stays on the field you clicked.
Repeated items are edited in place. Hovering an item floats a toolbar above it: move up/down, add the next one, remove. A new item is filled with empty values from the schema — default for text/textarea, the first option for enum, recursively for nested groups.
Edits apply optimistically — the value goes into the store and the component re-renders instantly — while the YAML write is debounced.
pnpm install
pnpm dev:prepare # build the module (stub) + prepare the example
pnpm dev # playground at localhost:3000
pnpm test # unit and transform tests
pnpm lint # eslint
pnpm typecheck # tsc --noEmitThe playground (example/) is a layout with a header and footer, two pages, and four sections: a hero, a repeatable list of services, a CTA with an enum, and a team on /about.
MVP. Working: text/textarea/enum, nested groups, type: 'repeat' repeated items, site-wide defineOneMeta, id inference from the path, the schema registry, the overlay with a navbar and a meta modal, add/remove/reorder of repeated items, and writing to YAML.
Not done yet: defineOneOptions behaviors (isolated copies, page sections), drag & drop instead of the ↑↓ buttons, wiring validation into the write, and HMR for schemas.
MIT © Vadym Bulakh