@phtngyn/cms

A single-site Nuxt CMS with an embedded Studio
2,147
Install
npm install @phtngyn/cms
Latest Version:0.11.0
Vue:3
License:MIT
Last Updated:Oct 13, 2026
Links: npm
Maintainer: phtngyn

@phtngyn/cms

A single-site Nuxt CMS with an embedded Studio. This package includes the CMS server, Studio, database migrations, and the cms CLI. Studio runs at /_cms/app on your website.

Requirements

Use Node 24.15 or later in the Node 24 release line, Nuxt 5 nightly, and Vue 3.6 prerelease. The package manifest defines the supported ranges. Run one long-lived Node process per installation, with writable persistent storage for SQLite and an S3-compatible bucket for media. This beta has no backward-compatibility or data-migration support for earlier CMS implementations.

Install

In a Nuxt project, install the CMS and its framework peers. The versions below match this release's tested workspace. Recheck them together when changing dependencies. For local verification, replace @phtngyn/cms in the install command with the path to the prepared tarball.

Keep one Vue release across the app, CMS, and compiler packages by adding these overrides to pnpm-workspace.yaml before installation:

overrides:
  '@nuxt/fonts': ^1.0.1
  vue: 3.6.0-rc.11
  '@vue/compiler-core': 3.6.0-rc.11
  '@vue/compiler-dom': 3.6.0-rc.11
  '@vue/compiler-sfc': 3.6.0-rc.11
  '@vue/shared': 3.6.0-rc.11
pnpm add @phtngyn/cms nuxt@npm:nuxt-nightly@5.0.0-2610121449-e69fd4e vue@3.6.0-rc.11
pnpm exec cms init --dry-run
pnpm exec cms init

Register the module in nuxt.config.ts:

export default defineNuxtConfig({
  modules: ['@phtngyn/cms/nuxt'],
})

cms init creates missing CMS configuration, a normal Nuxt content page, an environment template, and a setup guide. It preserves existing files. It edits a directly declared Nuxt configuration when safe; otherwise it prints the registration snippet above. Review the generated CMS_SETUP.md, then configure storage and credentials. Initialization creates no secrets or accounts and starts no services.

Configure cms.config.ts with inline collections and optional locales. Replace the bucket and region with your own; this example uses an existing bucket and the AWS credential provider chain.

import { defineCmsConfig, defineCmsCollection, defineLocales, field } from '@phtngyn/cms'

export default defineCmsConfig({
  locales: defineLocales({ defaultLocale: 'en', supported: [{ code: 'en' }, { code: 'de' }] }),
  collections: [
    defineCmsCollection({
      collection: 'pages',
      label: 'Pages',
      pattern: '/:path*',
      fields: { title: field.text({ required: true }) },
    }),
  ],
  seo: { siteName: 'My website' },
  runtime: {
    origin: 'http://127.0.0.1:3000',
    s3: { bucket: 'my-cms-media', region: 'us-east-1' },
  },
  $development: {
    bootstrap: { administrator: { name: 'Administrator' } },
  },
})

Your app owns its public routes. The generated app/pages/[...slug].vue is an ordinary Nuxt page:

<script setup lang="ts">
import { CmsDocumentRenderer, useCmsPage } from '@phtngyn/cms'

const { data: page } = await useCmsPage()
</script>

<template>
  <CmsDocumentRenderer v-if="page" :value="page" />
</template>

For another S3 provider, set runtime.s3.endpoint and, if required, forcePathStyle. Give each installation a separate bucket or prefix. Supply credentials and initial administrator details in a git-ignored .env; the environment reference lists all names, defaults, and requirements:

NUXT_CMS_S3_ACCESS_KEY_ID=your-access-key
NUXT_CMS_S3_SECRET_ACCESS_KEY=your-secret-key
NUXT_CMS_BOOTSTRAP_ADMINISTRATOR_EMAIL=you@example.com
NUXT_CMS_BOOTSTRAP_ADMINISTRATOR_PASSWORD=choose-a-password-of-at-least-eight-characters

Start the installation:

pnpm exec cms dev

The CLI generates a private installation secret in .env, starts Nuxt, and creates the initial administrator for a fresh installation. SQLite defaults to .data/cms.sqlite. In a second terminal, run pnpm exec cms doctor; once ready, open http://127.0.0.1:3000/_cms/app and sign in. Distributed installations use the included Studio assets.

Search and sharing

The module includes Nuxt SEO v6 for all public website pages. cms.config.ts.seo supplies developer defaults; administrators manage website name, description, logo, sharing image, and website search visibility in Studio. Save website changes, then Publish to apply them. Editors can override search title, description, sharing image, and visibility per Document. Hiding the entire website takes precedence over page visibility. Studio itself remains internal.

The default sharing image is generated from page and website details. Developers can declare templates with defineCmsSharingImageTemplate, using field declarations for the few values authors may edit. Register them in seo.templates, and place their Vue components in app/components/OgImage/ with a Nuxt OG Image renderer suffix such as .takumi.vue. Administrators choose the default; editors can select a template or uploaded image per page. Native Nuxt SEO renderer, font, and other module options stay in nuxt.config.ts.

Build and deploy

pnpm exec nuxt build
node .output/server/index.mjs

Set NUXT_CMS_ORIGIN to the public website origin, NUXT_CMS_DATABASE_FILE to a persistent SQLite path, NUXT_CMS_INSTALLATION_SECRET to the retained installation secret, and the matching NUXT_CMS_S3_* values for media storage. Builds accept missing deployment credentials; server readiness requires usable configuration. Prepare a new secret with cms prepare before startup. For a fresh production installation, configure bootstrap in the production profile and run cms setup --envName production against the running server to create the administrator.

Authoring and operations

All named imports come from @phtngyn/cms. Register the Nuxt module through @phtngyn/cms/nuxt. The root selects browser or Node capabilities for its environment; Node clients, protected server tools and offline operations load their resources when called.

Define editable Vue blocks among ordinary Nuxt components, for example app/components/blocks/. Vue defineProps and defineSlots own component contracts; defineCmsBlock<Props, Slots> marks editable values and adds Studio metadata. Omitted labels are humanized. Collection templates accept typed, partial authored fields. When supplying collection validate rules, directly export defineCmsConfig({ collections: ... }) and keep their dependencies in self-contained imports, simple constants, or local helpers. Imported validator modules own any initialization; their runtime imports use literal, resolvable paths and cannot import CMS configuration.

The module registers CmsDocumentRenderer, CmsImage, useCmsDocument, useCmsQuery, useCmsPage, useCmsDocumentSeo, useCmsI18n. For a site-owned document page, await useCmsPage() owns route loading, redirects, errors, preview destinations and SEO. useCmsDocument(path) reads an arbitrary address and exposes redirects explicitly. A collection query uses generated field types:

import { useCmsQuery } from '@phtngyn/cms'

const pages = await useCmsQuery('pages', {
  limit: 5,
  sort: [{ field: 'title', direction: 'asc' }],
})

Visitor Documents expose id, collection, path, optional configured fields, seo, content language and fieldIssues. Incomplete or incompatible retained fields stay absent; check them before use. Pass the complete Document to CmsDocumentRenderer so it retains prepared rendering and preview behavior. Query options accept objects, refs or getters and default to 20 results.

Visitor forms keep site-owned markup and messages. useCmsForm exposes a submission union for idle, submitting, success and transport errors; success distinguishes real submission from preview simulation. Validation leaves the form idle and exposes structured errors.

Create an ordinary Nuxt endpoint at server/api/forms/<block-name>.post.ts. useCmsForm() sends a JSON POST to that route automatically; no handler registration is needed in the block or configuration.

// server/api/forms/project-enquiry.post.ts
import { defineEventHandler } from 'nuxt/server'
import { readCmsForm } from '@phtngyn/cms'

export default defineEventHandler(async (event) => {
  const { values, request } = await readCmsForm(event)
  // Deliver values through your email or database service.
  return { status: 'accepted' as const, reference: request.id }
})

readCmsForm(event) checks the endpoint's block name, published form revision, language, authored rules and answer keys before returning form, values, and server-generated request metadata. Missing optional answers become null. It limits JSON bodies to 128 KiB, rejects cross-origin requests, and disables response caching. Invalid answers, stale definitions and unavailable forms throw native HTTP errors with structured data that useCmsForm() maps into its feedback. Your endpoint owns delivery and returns { status: 'accepted', reference? }. Business validation can return { status: 'invalid', errors } or throw a native HTTP error carrying that object in data. Unexpected server errors become transport failure feedback. Preview forms simulate success without sending requests.

Add site messages as flat JSON catalogs in app/i18n/en.json, app/i18n/de.json, and other canonical language filenames. The module discovers them automatically and generates message-key and interpolation types from the default language's file. Its default comes from cms.config.ts.locales.defaultLocale; a site without content locales sets cms: { i18n: { defaultLocale: 'en' } } in Nuxt configuration. Strings support named values such as "welcome": "Welcome, {name}"; plural messages supply other and optional Intl plural categories.

const { t, formatCalendarDate } = useCmsI18n()
t('welcome', { name: 'Ada' })
formatCalendarDate('2024-02-29')

Messages follow the requested URL language independently of authored content fallback. Missing translations use the default catalog. Formatting follows the requested language; calendar dates use UTC and return an empty string for invalid YYYY-MM-DD values. Development watches catalog edits, additions, and removals. Catalog files supply messages without changing content languages or routes. Studio independently offers English and German, selected through its browser-local preference.

useCmsI18n() also provides locale, locales, defaultLocale, direction, sitePath, localePath() and switchLocalePath(). These navigation helpers and formatting work without catalogs. Translation requires messages. With no content languages or message default, locale remains undefined, routes pass through, and formatting uses Intl’s environment default. Set cms.i18n.defaultLocale to choose a formatting language for a site without catalogs. This does not enable content languages or URL prefixes.

cms --help lists installed commands and flags. Configuration belongs in cms.config.ts; nuxt.config.ts registers the module. Keep collections and locales at the top level; use $development, $production, or $env profiles for environment-specific installation values, and select a CLI profile with --envName. Paths are relative to the consuming project; use --project <directory> from another directory.

For local S3 provisioning, declare provision: { compose: { file: './compose.yaml', service: 'your-s3-service' }, createBucket: true } in the development profile. The site owns that Compose file and its credentials. cms prepare starts the configured service and creates a missing bucket; omit provision when managing storage yourself.

To transfer authored content and media, use cms export ./site.cms.zip, then cms import ./site.cms.zip --dry-run on the destination before importing. A populated destination requires --replace to replace its content. Credentials and sessions remain local. To seed a fresh installation, set bootstrap.snapshot to an exported archive's project-relative path.

Administrator scripts use withCms(options, async cms => ...) for a connection that closes after success or failure. Its document, media and publication methods return Promises. Review a publication plan, then apply that exact plan; a stale plan requires a new review. connectCms supports manually owned lifetimes and must be closed in finally. Raw generated operations remain available through the scoped run callback; consume streams inside its scope.

The installed exports include TypeScript declarations. Snapshot transfer is separate from offline backup: stop the installation before calling the Promise-based backupCms or restoreCms. Backup requires a new directory; restore requires a fresh database path and media prefix.

License

Original CMS code is licensed under MIT. Bundled Studio dependencies retain their own licenses, included in studio/THIRD-PARTY-LICENSES.md.

Version History

Version Released License
0.11.0 MIT
0.10.0 MIT
0.9.0 MIT
0.8.0 MIT
0.7.0 MIT
Pre-releases (14)
Version Released License
0.2.0-beta.8 Proprietary
0.2.0-beta.7 Proprietary
0.2.0-beta.6 Proprietary
0.2.0-beta.5 Proprietary
0.2.0-beta.4 Proprietary