Skip to content

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:

bash
bunx nuxt-feathers-zod add service forms

The service remains the source of truth. Do not create a parallel Nitro endpoint for the same collection.

ts
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;
  • version for optimistic locking;
  • keep status separate from content;
  • keep defaultValues separate 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.

ts
// 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

ts
// 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

vue
<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 ownerId and tenantId;
  • 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.

QForm Builder — Nuxt 4, Quasar and FormKit layer for dynamic forms.