@liria24/site-admin
@liria24/site-admin
What does it do?
Site Admin adds schema-driven content management to a Nuxt application: typed models, drafts and revisions, publishing, localized routes, assets, SEO and optional AI suggestions.
Build your own public pages and administration UI. Site Admin supplies public and authenticated management APIs, typed composables and headless forms. Your application owns its database connection, migrations, authentication providers and AI model.
Install
bun add @liria24/site-admin
# For the application-owned Drizzle example and schema generation below:
bun add drizzle-orm@1.0.0-rc.4 @better-auth/drizzle-adapter
bun add -D drizzle-kit@1.0.0-rc.4
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@liria24/site-admin/nuxt'],
site: { name: 'Example', url: 'https://example.com' },
i18n: { defaultLocale: 'en', locales: ['en'] },
llms: { domain: 'https://example.com', title: 'Example' },
})
The module installs its integrations. Configure their native Nuxt options as needed; use siteAdmin switches to disable integrations you do not use. Database drivers and AI providers remain application-selected dependencies.
Define content
Keep models and server configuration together in site-admin.config.ts:
import { defineSiteAdminConfig, markdown, text, textarea } from '@liria24/site-admin'
import { drizzleAdapter } from '@liria24/site-admin/adapters/drizzle'
export default defineSiteAdminConfig({
database: async () => {
const [{ getAppDb }, schema] = await Promise.all([
import('./server/database'),
import('./server/database/schema'),
])
return drizzleAdapter(await getAppDb(), { schema })
},
seo: { titleTemplate: '%s | Example' },
models: {
posts: {
route: '/posts/:slug',
displayFields: { title: 'title', description: 'excerpt' },
fields: {
title: text({ required: true }),
excerpt: textarea({ maxLength: 180 }),
body: markdown({ required: true }),
},
},
},
})
Create the application helper server/database.ts to return your chosen Drizzle SQLite or D1 connection. The resolver runs when a request or task needs the adapter; use its { event, request, platformContext } argument for request-bound connections. Your application controls caching and disposal. See the application connection example and adapter protocol.
Management search queries accept at most 512 Unicode code points before and after lowercase mapping; longer queries return INVALID_INPUT (HTTP 400). Unicode management search keeps bounded derived-text chunks in existing metadata, leaving canonical columns and history unchanged. Cold projections use at most 24 preparation statements per request (up to 256 small heads); the management client resumes search GETs only while reported preparation progresses. Filtering, counts and pages remain in one native database snapshot, including matches across chunk boundaries. Other requests are not retried.
For example, a Node application can own a lazily opened SQLite connection:
// server/database.ts
import { drizzle } from 'drizzle-orm/node-sqlite'
import * as schema from './database/schema'
let db: ReturnType<typeof drizzle> | undefined
export const getAppDb = () => (db ??= drizzle('./.data/content.sqlite3', { relations: schema.authRelations }))
Create the data directory and close the connection in your application's lifecycle. For D1, replace this helper with one that reads the current application's binding.
Configure Better Auth directly in server/auth.config.ts, using defineServerAuth from #nuxtjs/better-auth/config and your application-owned auth adapter. Keep its connection and generated schema consistent with the content adapter. Add app/auth.config.ts with the native client configuration, and set NUXT_BETTER_AUTH_SECRET. The server and client fixtures show these native configurations. Defer opening connections during schema inspection.
# Bootstrap the content schema before auth configuration imports it.
bun x site-admin generate --out server/database/schema.ts
# Then include the native Better Auth tables and plugins.
bun x site-admin generate --auth server/auth.config.ts --out server/database/schema.ts
bun x drizzle-kit generate --config drizzle.config.ts
bun x drizzle-kit migrate --config drizzle.config.ts
Point your application-owned drizzle.config.ts at that schema and your chosen migration directory/database. Review generated migrations and apply them explicitly; Site Admin never opens a built-in connection or applies migrations. For D1, apply SQL with your deployment tooling. Auth generation uses the native adapter and its provider/table options from your auth configuration; combined content schemas currently support SQLite/D1.
Saves create drafts until publication; set a model's publishing: false for immediate publication. Models are public by default; public: false removes anonymous access. Fields include relations, files, images, arrays and objects, with optional Standard Schema validation. See model/config types.
Use it in the app
Nuxt infers model names and field types from the common configuration:
// Inside a page's <script setup>
const route = useRoute()
const entry = await useSiteAdminEntry('posts', () => String(route.params.slug), {
default: () => null,
})
const posts = await useSiteAdminList('posts', { default: () => [], lazy: true })
const summary = computed(() => entry.data.value?.data.body.meta.summary)
const batch = await useSiteAdminBatch({
posts: { list: 'posts' },
featured: { entry: 'posts', slugOrId: () => String(route.params.slug) },
})
useSeo(() => entry.data.value?.seo)
Pages using explicit useSeo() can set siteAdmin.routing.metadata: false in nuxt.config.ts to disable automatic route metadata while retaining routing and the SEO helpers.
These composables return native Nuxt AsyncData: data, error, status, refresh, execute and clear. They accept native options such as transform, pick, default, watch, server, lazy, dedupe and timeout, plus a reactive locale. With siteAdmin.i18n enabled, an omitted locale follows the installed i18n locale; otherwise set locale explicitly when needed. Reactive slugs/locales update the request and SSR payload is reused during hydration.
Shared useSiteAdminEntry, useSiteAdminList, useSiteAdminBatch and management composables keep a single native AsyncData subscription per key. Additional callers attach to that entry, including after a reactive slug or locale changes, so an application does not patch Nuxt for the shared-key construction race in Nuxt 4.6.1.
Public entries have typed entry.data.value?.data.title; Markdown fields are parsed Comark documents; see Markdown handling. A batch preserves each named model's type and exposes its own { data, error }, for example batch.data.value?.featured.data?.data.title. A missing entry is null; unknown/private model names are rejected by the generated types. See public data examples and inference checks.
entry.data.value?.data.body.meta.summary is typed as Node[] | undefined. An explicit native Comark summary before <!-- more --> wins, including an empty summary. Otherwise it contains up to two body paragraphs and 280 graphemes, excluding images, code and raw HTML. The body AST is unchanged; markdown.summary.enabled: false disables both. Render the AST with Comark and use CSS for the visible line limit; no AI runs.
Use useSiteAdminList('posts', { markdown: 'summary' }) or { list: 'posts', markdown: 'summary' } in a batch to send rendering documents whose nodes contain only the summary, including nested and related Markdown. Missing summaries and summary.enabled: false have empty nodes, including when custom plugins provide summaries. Source, frontmatter and other Markdown plugin metadata are omitted on the server before HTTP/SSR serialization; default lists and details retain full content and separate native cache keys. This reduces payloads, not database reads or parsing.
Native transformations preserve their output type:
const titles = await useSiteAdminList('posts', {
transform: (entries) => entries.map((entry) => entry.data.title),
default: () => [],
})
// titles.data.value is string[].
For editing, use the typed management client:
const management = useSiteAdminManagementClient()
const entries = await management.listAllEntries('posts')
Management APIs cover drafts, revisions, publishing, scheduling, ordering, assets and AI proposals. Pass the current expectedVersion for direct client edits and publication. Install the optional @tanstack/vue-form peer and await the model form during component setup:
const create = await useSiteAdminForm('posts', {})
const editor = await useSiteAdminForm('posts', { id: () => props.id })
const list = await useSiteAdminManagementList('posts') // locale is explicit for management
await editor.form.handleSubmit() // save a draft, without AI
// After any app-selected action and explicit apply:
await editor.publish() // atomically stores and publishes the current candidate
// Or: await editor.schedule(atISOString)
// editor.form fields and list.data.value?.items infer the selected model.
// Assets expose field.value.url / item.data.image.url / uploaded.url.
The form loads its descriptor and entry, retains session drafts, tracks dirty and baseVersion, and saves with optimistic concurrency. Draft saves never invoke AI. Empty draft slugs require a final slug before publication. Native ai.actions returning { data, slug? } proposals connect to editor.ai.run(action, props) with inferred input types, including new entries; explicitly apply({ fields, slug }) or discard. Prompts, output schemas, metadata generation and proofreading policies belong to your application. ai.proposal, busy, error and stale support explicit apply/discard without saving. A dirty refresh preserves edits and reports version conflicts. Drafts are isolated by connection, actor, model, locale and ID; use key to separate simultaneous new entries. Layout, editor rendering, toasts and navigation remain application-owned. The { entry } and Core descriptor overloads remain available; see the headless form API.
Migration: controller.metadata.modes, setMode() and session-draft metadata are removed. Keep generation choices in application UI and reactive props passed to ai.run(). controller.metadata.slug is unchanged.
Direct publishEntry and schedulePublish accept either revisionId or an optional draft: { data, slug }, with expectedVersion; candidates require update permission as well as publish/schedule permission. Revision, route and version writes are atomic. draft.serialize() returns a typed raw snapshot and its base version. A new form must first save its draft to establish an ID before an entry action, publication or scheduling.
Successful Nuxt management-client mutations clear and refresh affected model lists, entry data and dependent batches through native Nuxt APIs. Refresh failures do not undo a successful save. Nuxt's public key enumeration covers serialized payload data; serialize: false reads need explicit refresh, and an application-owned getCachedData or extracted static cache must invalidate its own cache. Native options and manual refresh remain available. Display URLs use the authenticated asset route and are stripped from schema-declared asset values on save; they do not grant public access.
Optional settings and reference
- Files: put native Files SDK
storageand environment overrides in the common file.assetscontrols upload/publication policy. An existingfiles.config.tstakes precedence for physical storage. See Files selection and asset policy. - AI:
ai.actionsdefines schema-backedprops, per-action/shared models, and native text-generationprompt/Outputor decisionstate/questions.useAiAction(name, { props, immediate: false }), management clientrunAiAction(name, { props })and serverrunAiAction(event, name, { props })infer input/output. Sessions and roleai: [name]permissions protect execution. Native NuxtcreateUseFetchowns SSR/hydration and status/error: POST,watch: false, zero retries;execute()reports failure inerror, andexecute({ signal })/clear()cancel.server: falsecannot await client data during setup. Output factories receive validated input for app-owned preservation/DB guards. AI-free config omits the capability. One-offcreateSiteAdminAI(model, context).generateText()stays native. See AI types. - SEO and routes: common
seosupplies defaults;models.posts.seoaccepts an object or synchronous entry resolver. Top-level commonrouteRulesappliesseo,sitemapandllmsrules to actual pathnames, including i18n prefixes such as/ja/posts/**.useSeo()applies reactive entry metadata and optional page overrides. See SEO/route types. - OG images: configure the native
nuxt-og-imagerenderer and install its renderer dependencies in your application.useSeo()accepts its component/props/options descriptor when that integration is enabled. - Tasks: opt in with
tasks: { publishDue: '* * * * *', syncAssets: true }. A cron string schedules the native Nitro task;trueallows manual runs. Your application supplies database/platform context. See task API. - Owned imports: use the seven
#better-auth,#nuxtjs/better-auth,#nuxt-files-sdk,#files-sdk,#comark,#comark-contentand#ainamespaces for their 24 supported entrypoints. They work after module setup and in the schema CLI; use ordinary imports innuxt.config.ts. Standalone runners can opt into the exported dependency plugin/type paths. Jiti configuration aliases retain Jiti's prefix semantics.
Management defaults to /api/site-admin/**; public content uses /api/content/**. Missing sessions receive 401 and insufficient permissions 403. Grant the first administrator its native Better Auth admin role through a trusted setup path; signup is not automatically elevated. Disabling siteAdmin.auth disables management HTTP routes.
Published-asset checks do not make a public bucket private. For private originals, explicitly enable assets.separateDrafts and provide a genuinely private draft storage. Keep draft/management responses out of shared caches.
Supported integration: Nuxt 4.6 with its default Nitro 2 builder. Check the package's Node engine requirement. For implementation details, use the Nuxt options, core server API and test suite. Contributors run bun run check; CI also validates packed consumers and native runtimes. Releases use the existing release workflow.
Version History
| Version | Released | Vue | License |
|---|---|---|---|
| 0.0.3 | ^3.6.0-rc.2 | MIT | |
| 0.0.2 | ^3.6.0-rc.2 | MIT | |
| 0.0.1 | ^3.6.0-rc.2 | MIT | |
| 0.0.0 | ^3.6.0-rc.2 | MIT |