ghots-nuxt-cms
v0.0.4
Published
Static-first Supabase page builder for Nuxt 4
Maintainers
Readme
ghots-nuxt-cms
Static-first Supabase page builder for Nuxt 4.
Developers define templates, section components, and globals in Vue, tagging editable nodes with data-name, data-type, and data-id. Editors change content on the live site (sidebar + modal). Guests get fast prerendered HTML from your last nuxt generate — no runtime database calls.
How it works
| Audience | What they see |
| -------------------- | ------------------------------------------ |
| Guest | Last published static build (dist/) |
| Logged-in editor | Live Supabase data; edits save immediately |
| You (developer) | Templates, sections, DOM markup in Vue |
Publish = run nuxt generate and deploy dist/ so guests catch up with editor changes.
Requirements
- Node.js 20+
- Nuxt 4
- A Supabase project (Postgres, Auth, Storage)
Install
npm install ghots-nuxt-cms @supabase/supabase-js// nuxt.config.ts
export default defineNuxtConfig({
extends: ['ghots-nuxt-cms'],
runtimeConfig: {
public: {
supabaseUrl: process.env.VITE_SUPABASE_URL ?? '',
supabaseAnonKey: process.env.VITE_SUPABASE_ANON_KEY ?? '',
cmsSiteKey: process.env.CMS_SITE_KEY ?? 'demo',
},
},
nitro: {
prerender: {
routes: ['/'], // every public URL in your static build
},
},
})# .env
VITE_SUPABASE_URL=https://your-project.supabase.co
VITE_SUPABASE_ANON_KEY=your-anon-key
CMS_SITE_KEY=demoCMS_SITE_KEY must match a row in the sites table (see migrations below).
Supabase setup
- Apply SQL migrations from
node_modules/ghots-nuxt-cms/supabase/migrations/in order (001→007). - Create an email/password user in Supabase Auth for editors.
- Grant site access:
insert into site_members (site_id, user_id)
select s.id, 'USER-UUID-HERE'
from sites s
where s.key = 'demo';Note: Migration 007 enables multi-site support and re-seeds demo sites. Read the migration before applying on an existing database.
Tables, RLS, and storage are documented in the ghots-nuxt-cms repo.
What you provide
The layer ships editor UI, composables, auth, and DB schema. Your app provides content definitions and site chrome:
| File / folder | Purpose |
| -------------------------------- | ---------------------------------------------------- |
| app/cms/registries.ts | Required — exports template and global resolvers |
| app/composables/useTemplate.ts | Maps DB template keys → Vue SFCs |
| app/templates/*.vue | Page layouts — tag fields with data-* attrs |
| app/sections/*.vue | Reusable section components (optional) |
| app/globals/registry.ts | Shared nav/footer/settings (optional) |
| app/pages/[...slug].vue | Catch-all page using useCmsPage() |
| app/app.vue | Site shell + <CmsSidebar v-if="loggedIn" /> |
Registries barrel
// app/cms/registries.ts
export { resolveTemplateComponent } from '~/composables/useTemplate'
export { getGlobalDefinition, listGlobalDefinitions } from '~/globals/registry'Minimal template
insert into templates (key, label, field_schema)
values ('default', 'Default page', '[]'::jsonb);
insert into pages (slug, title, template_id)
select '/', 'Home', id from templates where key = 'default';<!-- app/templates/DefaultPage.vue -->
<script setup lang="ts">
import type { FieldRow } from '~/types/cms'
const props = defineProps<{
pageId: string
fieldsByParentAndName: Record<string, FieldRow>
}>()
const titleField = computed(() =>
useCmsField(props.fieldsByParentAndName, null, 'title'),
)
</script>
<template>
<main data-type="page" :data-id="pageId">
<h1
data-name="title"
data-type="plain_text"
:data-id="titleField.id"
>
{{ cmsColumnValue(titleField, 'plain_text') }}
</h1>
</main>
</template>Every CMS node needs data-name, data-type, and :data-id. useCmsField and cmsColumnValue are auto-imported. Full reference: DOM markup.
Catch-all page
<!-- app/pages/[...slug].vue -->
<script setup lang="ts">
const { content, status, templateComponent, patchField, loggedIn } =
useCmsPage()
</script>
<template>
<div v-if="status === 'pending'">Loading…</div>
<div v-else-if="!content"><h1>404</h1></div>
<PageEditorProvider
v-else-if="templateComponent"
:enabled="loggedIn"
:fields-by-id="content.fieldsById"
:fields-by-parent-and-name="content.fieldsByParentAndName"
@field-updated="patchField"
>
<component
:is="templateComponent"
:page-id="content.page.id"
:fields="content.fields"
:fields-by-parent-and-name="content.fieldsByParentAndName"
/>
</PageEditorProvider>
</template>Run and publish
npm run dev # editors: live Supabase at /login
npm run generate # bake static site → dist/Deploy dist/ to any static host. Do not use npm run dev to test guest behavior — dev always hits Supabase.
Field types
Built-in types: plain_text, link, richtext, image, array.
Use <CmsLink>, <CmsRichText>, <CmsImage> in templates — they set data-name, data-type, and :data-id automatically. Mark plain elements by hand; see DOM markup.
Environment variables
| Variable | Required | Description |
| ------------------------- | -------- | --------------------------------------------- |
| VITE_SUPABASE_URL | Yes | Supabase project URL |
| VITE_SUPABASE_ANON_KEY | Yes | Supabase anon/public key |
| CMS_SITE_KEY | Yes | Site key for this deployment (sites.key) |
| CMS_PUBLISH_WEBHOOK_URL | No | Future CI hook placeholder (not called in v1) |
Documentation
Full guides (templates, slices, globals, publishing, examples):
github.com/Bohdan-Anderson/ghots-nuxt-cms/tree/main/docs
Reference apps in that repo:
demo/— full-featured example + E2Eexamples/minimal/— smallest working install
License
MIT
