Learn how to work with Nuxt Crouton's form system - from adding custom fields and validation to understanding the underlying architecture.
Generated forms provide a foundation - you extend them by adding custom fields as needed.
Add a category dropdown to a product form:
<!-- layers/shop/components/products/_Form.vue -->
<script setup lang="ts">
// Keep generated form setup
const props = defineProps<ShopProductsFormProps>()
const { create, update } = useCollectionMutation('shopProducts')
// Add: Fetch related collection for dropdown
const { items: categories } = await useCollectionQuery('shopCategories') // See /fundamentals/querying for query patterns
</script>
<template>
<UForm :state="state" :schema="schema" @submit="handleSubmit">
<!-- Generated fields -->
<UFormField label="Name" name="name">
<UInput v-model="state.name" />
</UFormField>
<UFormField label="Price" name="price">
<UInput v-model.number="state.price" type="number" />
</UFormField>
<!-- Custom field: Category dropdown -->
<UFormField label="Category" name="categoryId">
<USelectMenu
v-model="state.categoryId"
:options="categories"
option-attribute="name"
value-attribute="id"
placeholder="Select category"
/>
</UFormField>
<CroutonFormActionButton :action="action" :loading="loading" />
</UForm>
</template>
For large datasets, add search functionality:
<script setup lang="ts">
const { items: categories } = await useCollectionQuery('shopCategories')
const searchQuery = ref('')
const filteredCategories = computed(() =>
categories.value.filter(c =>
c.name.toLowerCase().includes(searchQuery.value.toLowerCase())
)
) // For advanced filtering, see /fundamentals/querying
</script>
<template>
<UFormField label="Category" name="categoryId">
<UInput v-model="searchQuery" placeholder="Search categories..." class="mb-2" />
<USelectMenu
v-model="state.categoryId"
:options="filteredCategories"
option-attribute="name"
value-attribute="id"
/>
</UFormField>
</template>
Show loading indicators while fetching data:
<script setup lang="ts">
const { items: categories, pending: loadingCategories } = await useCollectionQuery('shopCategories')
</script>
<template>
<UFormField label="Category" name="categoryId">
<USelectMenu
v-model="state.categoryId"
:options="categories"
:loading="loadingCategories"
/>
</UFormField>
</template>
Nuxt Crouton uses Zod for schema validation.
// layers/shop/composables/useProducts.ts
import { z } from 'zod'
export function useShopProducts() {
const schema = z.object({
// Generated fields
name: z.string().min(1, 'Name is required'),
price: z.number().min(0, 'Price must be positive'),
// Custom validation rules
sku: z.string()
.regex(/^[A-Z]{3}-\d{4}$/, 'SKU must be format: ABC-1234'),
email: z.string()
.email('Must be a valid email address'),
url: z.string()
.url('Must be a valid URL')
.optional(),
quantity: z.number()
.int('Must be a whole number')
.min(0)
.max(9999, 'Quantity cannot exceed 9999')
})
// Rest of composable...
}
Validate relationships between multiple fields:
const schema = z.object({
price: z.number().min(0, 'Price must be positive'),
discountPrice: z.number().optional()
}).refine((data) => {
// Ensure discount is less than regular price
if (data.discountPrice && data.discountPrice >= data.price) {
return false
}
return true
}, {
message: 'Discount price must be less than regular price',
path: ['discountPrice'] // Show error on discountPrice field
})
.refine() in the Zod documentation.Validate against your database or external APIs:
const schema = z.object({
name: z.string().min(1),
// Check if SKU already exists
sku: z.string().refine(async (sku) => {
const exists = await $fetch(`/api/products/check-sku?sku=${sku}`)
return !exists
}, 'SKU already exists'),
// Validate against external API
domain: z.string().refine(async (domain) => {
const isValid = await $fetch(`/api/verify-domain?domain=${domain}`)
return isValid
}, 'Domain is not accessible')
})
Apply different rules based on field values:
const schema = z.object({
type: z.enum(['physical', 'digital']),
weight: z.number().optional(),
downloadUrl: z.string().optional()
}).refine((data) => {
// Physical products must have weight
if (data.type === 'physical' && !data.weight) {
return false
}
return true
}, {
message: 'Weight is required for physical products',
path: ['weight']
}).refine((data) => {
// Digital products must have download URL
if (data.type === 'digital' && !data.downloadUrl) {
return false
}
return true
}, {
message: 'Download URL is required for digital products',
path: ['downloadUrl']
})
.refine() and conditional logic, see the Zod documentation.Understanding how Nuxt Crouton's form system works helps you customize it effectively.
User Action (Click "Create")
↓
useCrouton().open('create', 'users', [])
↓
Form.vue (Global Container)
├─ Renders: Modal | Slideover | Dialog
├─ Manages: State via useCrouton()
└─ Delegates to: FormDynamicLoader
↓
FormDynamicLoader.vue
├─ Resolves: collection → component mapping
└─ Loads: [Collection]Form.vue
↓
UsersForm.vue (Generated)
├─ Uses: FormLayout for structure
├─ Uses: UForm for validation
├─ Renders: Field components
└─ Submits: via useCollectionMutation()
↓
Data Layer (useCollectionMutation)
├─ create() / update() / deleteItems()
├─ Optimistic updates
├─ Cache invalidation
└─ Toast notifications
Global State (useCrouton):
const { open, close, closeAll, croutonStates } = useCrouton()
// Open form
open('create', 'users', []) // Create in slideover
open('update', 'users', ['user-123'], 'modal') // Edit in modal
open('delete', 'users', ['id1', 'id2']) // Delete confirmation
open('view', 'users', ['user-123']) // View-only
// Close forms
close() // Close current
closeAll() // Close all forms
Local Form State (per component):
<script setup lang="ts">
const props = defineProps<{
action: 'create' | 'update' | 'delete'
loading: string
activeItem?: any
}>()
// Local reactive state
const state = ref({
id: props.activeItem?.id || null,
name: props.activeItem?.name || '',
email: props.activeItem?.email || '',
})
// Validation schema
const schema = z.object({
name: z.string().min(1),
email: z.string().email()
})
// Form submission
const handleSubmit = async () => {
const { create, update } = useCollectionMutation('users')
if (props.action === 'create') {
await create(state.value)
} else if (props.action === 'update') {
await update(state.value.id, state.value)
}
close()
}
</script>
Modal - Standard forms, simple edits:
Slideover - Complex forms, nested workflows:
Dialog - Simple confirmations:
FormReferenceSelect - Select related entities:
<CroutonFormReferenceSelect
v-model="state.categoryId"
collection="categories"
:multiple="false"
/>
Features: Single/multi-select, searchable, inline creation, auto-selection
FormRepeater - Manage arrays of structured data:
<CroutonFormRepeater
v-model="state.contacts"
:component="ContactItem"
/>
Features: Add/remove, drag-to-reorder, dynamic components
FormDependentFieldLoader - Conditional fields:
<CroutonFormDependentFieldLoader
:depends-on="['category']"
:values="{ category: state.category }"
component-path="CategorySpecificFields"
/>
open('create', 'users', [])handleSubmit() calls create(data)open('update', 'users', ['user-123'])activeItem datahandleSubmit() calls update(id, data)Example: Creating a product and adding a new category inline
open('create', 'products', []) → Product form (Level 1)open('create', 'categories', []) → Category form (Level 2, nested)Show different fields based on selected category:
<template>
<!-- Category selector -->
<UFormField label="Category" name="category">
<CroutonFormReferenceSelect
v-model="state.category"
collection="categories"
/>
</UFormField>
<!-- Dependent field loader -->
<CroutonFormDependentFieldLoader
:depends-on="['category']"
:values="{ category: state.category }"
component-path="CategorySpecificFields"
/>
</template>
Use tabs with validation per step:
<script setup lang="ts">
const activeSection = ref('step1')
const navigationItems = [
{ label: 'Basic Info', value: 'step1' },
{ label: 'Details', value: 'step2' },
{ label: 'Review', value: 'step3' }
]
</script>
<template>
<CroutonFormLayout
:tabs="true"
:navigation-items="navigationItems"
v-model="activeSection"
>
<!-- Step content with v-show -->
</CroutonFormLayout>
</template>
Track errors per tab:
// Field → Tab mapping
const fieldToGroup: Record<string, string> = {
'name': 'general',
'email': 'general',
'metaTitle': 'seo',
'metaDescription': 'seo'
}
const validationErrors = ref<any[]>([])
const handleValidationError = (event: any) => {
if (event?.errors) {
validationErrors.value = event.errors
}
}
// Count errors per tab
const tabErrorCounts = computed(() => {
const counts: Record<string, number> = {}
validationErrors.value.forEach(error => {
const tabName = fieldToGroup[error.name] || 'general'
counts[tabName] = (counts[tabName] || 0) + 1
})
return counts
})
When adding custom fields:
<template>
<UForm>
<!-- Generated fields -->
<UFormField label="Name" name="name">
<UInput v-model="state.name" />
</UFormField>
<!-- Custom fields - Added for category support -->
<UFormField label="Category" name="categoryId">
<USelectMenu v-model="state.categoryId" :options="categories" />
</UFormField>
<!-- Keep generated button -->
<CroutonFormActionButton :action="action" :loading="loading" />
</UForm>
</template>
Add custom fields to your type definitions:
// layers/shop/types/products.ts
export interface ShopProduct {
// Generated fields
id: string
name: string
price: number
// Custom fields
categoryId?: string
sku?: string
tags?: string[]
}
For large forms (50+ fields):
For nested forms:
Cache management:
Symptoms: Clicking create/edit does nothing
Solutions:
npx crouton-generate config crouton.config.jscomponents/[Collection]Form.vueSymptoms: No error, no success toast, form doesn't close
Solutions:
close() is called after successSymptoms: Form submits with invalid data
Solutions:
@error handler attachedNuxt Crouton's form system provides:
For most use cases, the generated forms "just work". For advanced scenarios, every piece is customizable while maintaining the core architecture.