Persist forms with NFZ
The Community Edition does not couple FormBuilder to a database. A Nuxt 4 application can store schemas in a Feathers service generated with nuxt-feathers-zod, then expose that service through a typed store or repository.
1. Generate the forms service
In the NFZ client application:
bunx nuxt-feathers-zod add service formsThe service remains the source of truth. Do not create a parallel Nitro endpoint for the same collection.
2. Recommended data contract
import type {
FormBuilderSchema,
FormBuilderSettings,
FormBuilderValues,
} from '@vevedh/qform-builder-layer/types'
export interface FormRecord {
_id: string
slug: string
name: string
description?: string
schema: FormBuilderSchema
defaultValues: FormBuilderValues
settings: FormBuilderSettings
status: 'draft' | 'published' | 'archived'
version: number
ownerId?: string
tenantId?: string
createdAt: string
updatedAt: string
}
export type FormData = Omit<FormRecord, '_id' | 'createdAt' | 'updatedAt'>
export type FormPatch = Partial<Omit<FormData, 'slug'>> & { version: number }Recommendations:
- a tenant-scoped unique index on
slug; versionfor optimistic locking;- keep
statusseparate from content; - keep
defaultValuesseparate from user submissions; - do not ship personal data in examples or templates.
3. Typed Feathers repository
Critical pages should not call $api.service(...) directly. Wrap the service behind a repository created from useAdminFeathers() or the application's central Feathers access layer.
// app/repositories/formsRepository.ts
import type {
FormBuilderSavePayload,
FormBuilderSchema,
FormBuilderSettings,
FormBuilderValues,
} from '@vevedh/qform-builder-layer/types'
import { sanitizeQFormBuilderSchema } from '@vevedh/qform-builder-layer/schema-transfer'
interface FormRecord {
_id: string
slug: string
name: string
schema: FormBuilderSchema
defaultValues: FormBuilderValues
settings: FormBuilderSettings
status: 'draft' | 'published' | 'archived'
version: number
createdAt: string
updatedAt: string
}
interface FormsService {
find(params?: { query?: Record<string, unknown> }): Promise<{ data: FormRecord[] } | FormRecord[]>
get(id: string): Promise<FormRecord>
create(data: Omit<FormRecord, '_id' | 'createdAt' | 'updatedAt'>): Promise<FormRecord>
patch(id: string, data: Partial<FormRecord>, params?: { query?: Record<string, unknown> }): Promise<FormRecord>
}
export function createFormsRepository(service: FormsService) {
async function findBySlug(slug: string): Promise<FormRecord | null> {
const result = await service.find({ query: { slug, $limit: 1 } })
const records = Array.isArray(result) ? result : result.data
return records[0] || null
}
async function load(id: string): Promise<FormRecord> {
const record = await service.get(id)
return { ...record, schema: sanitizeQFormBuilderSchema(record.schema) }
}
async function createDraft(slug: string, payload: FormBuilderSavePayload): Promise<FormRecord> {
return service.create({
slug,
name: payload.settings.formName || slug,
schema: sanitizeQFormBuilderSchema(payload.schema),
defaultValues: payload.values,
settings: payload.settings,
status: 'draft',
version: 1,
})
}
async function save(id: string, expectedVersion: number, payload: FormBuilderSavePayload): Promise<FormRecord> {
return service.patch(id, {
name: payload.settings.formName || 'Form',
schema: sanitizeQFormBuilderSchema(payload.schema),
defaultValues: payload.values,
settings: payload.settings,
version: expectedVersion + 1,
}, {
query: { version: expectedVersion },
})
}
return { findBySlug, load, createDraft, save }
}The service or hook must enforce query: { version: expectedVersion }. Return a Feathers/HTTP 409 conflict when no record matches so concurrent changes are not overwritten.
4. Pinia editing-session store
// app/stores/formsEditor.ts
import { defineStore } from 'pinia'
import type { FormBuilderSavePayload } from '@vevedh/qform-builder-layer/types'
import { createFormsRepository } from '~/repositories/formsRepository'
export const useFormsEditorStore = defineStore('forms-editor', () => {
const currentId = ref<string | null>(null)
const currentVersion = ref(0)
const saving = ref(false)
async function save(payload: FormBuilderSavePayload) {
if (!currentId.value) throw new Error('No active form.')
const { api } = useAdminFeathers()
const repository = createFormsRepository(api.service('forms'))
saving.value = true
try {
const saved = await repository.save(currentId.value, currentVersion.value, payload)
currentVersion.value = saved.version
return saved
}
finally {
saving.value = false
}
}
return { currentId, currentVersion, saving, save }
})useAdminFeathers() represents the protected access layer. It must run ensureAuthenticated() and enforce RBAC before returning the service.
5. Connect FormBuilder
<script setup lang="ts">
import type {
FormBuilderSchema,
FormBuilderSettings,
FormBuilderValues,
} from '@vevedh/qform-builder-layer/types'
const editor = useFormsEditorStore()
const schema = ref<FormBuilderSchema>([])
const values = ref<FormBuilderValues>({})
const settings = ref<Partial<FormBuilderSettings>>({})
</script>
<template>
<FormBuilder
builder-id="admin-forms-editor"
v-model:schema="schema"
v-model:values="values"
v-model:settings="settings"
:disabled="editor.saving"
@save="editor.save"
/>
</template>6. Required backend security
The forms service must enforce at least:
- NFZ/Feathers authentication;
- RBAC on
find/get/create/patch/remove; - schema payload limits;
- Zod validation;
- server-side schema sanitization;
- server-owned
ownerIdandtenantId; - client-side updates to those fields forbidden;
- optimistic locking through
version; - publication and deletion auditing;
- external resolvers hiding unnecessary internal metadata.
Store user responses in a separate service such as form-submissions so form definitions and collected personal data remain separated.