Schema files define the structure of your collections, specifying fields, types, validation rules, and metadata. A schema is a JSON object where keys are field names and values are field configurations:
{
"title": {
"type": "string",
"meta": {
"required": true
}
},
"description": {
"type": "text"
},
"price": {
"type": "number",
"meta": {
"default": 0
}
},
"inStock": {
"type": "boolean",
"meta": {
"default": true
}
},
"publishedAt": {
"type": "date"
}
}
The generator automatically adds certain fields to every collection based on your configuration. You should NEVER define these fields in your schema JSON files - doing so will cause duplicate key errors.
These fields are always added to every collection, regardless of configuration:
id - Primary key (UUID for PostgreSQL, nanoid for SQLite)All generated collections are team-scoped by default. The generator automatically adds:
teamId - Team/organization reference for multi-tenancy (required, text field)owner - User who created the record (required, text field)These fields enable team-based authentication and automatically scope all data to the current team:
@fyit/crouton-auth/server for authenticationSee Team-Based Authentication for complete details.
useMetadata: true (default)The generator automatically adds:
createdAt - Timestamp when record was created (auto-populated on insert)updatedAt - Timestamp when record was last modified (auto-updated on change)createdBy - User ID of who created the record (auto-populated on insert)updatedBy - User ID of who last modified the record (auto-updated on change)When useMetadata: false: These fields are NOT added, giving you full control over timestamp and audit tracking.
[esbuild] (schema.ts:8:2) Duplicate key "owner" in object literal
[esbuild] (schema.ts:9:2) Duplicate key "teamId" in object literal
{
"owner": { // ❌ Don't define this!
"type": "string",
"refTarget": "users"
},
"teamId": { // ❌ Don't define this!
"type": "string"
},
"title": { // ✅ Only define your custom fields
"type": "string"
}
}
useMetadata: true (default), the system automatically tracks:owner - Who created the record (set once on creation)createdBy / updatedBy - Who created/last modified the recordcreatedAt / updatedAt - When the record was created/modifiedowner, createdBy, and updatedBy are all set to the creating user. On update operations, updatedBy automatically changes to whoever made the modification.Nuxt Crouton supports the following field types:
string - Short text (single line input)text - Long content (textarea)number - Integersdecimal - Precise decimal values (e.g., money)boolean - True/false values (checkbox)date - Date/timestamp valuesjson - JSON objectsimage - Image asset reference (stores asset ID, renders CroutonAssetsPicker with crop)file - File asset reference (stores asset ID, renders CroutonAssetsPicker without crop)repeater - Arrays of structured data (see Repeater Fields)array - Arrays of strings (stored as JSON text)Reference fields create relationships between collections using the refTarget property. The generator automatically creates UI components for selecting and displaying related items.
{
"authorId": {
"type": "string",
"refTarget": "authors",
"meta": {
"required": true,
"label": "Author"
}
}
}
type: "string" because they store the referenced item's ID (UUID or nanoid string).When you define a reference field, the generator creates:
ReferenceSelect dropdown with:CardMini cell showing:{
"title": {
"type": "string",
"meta": {
"required": true,
"label": "Article Title"
}
},
"authorId": {
"type": "string",
"refTarget": "authors",
"meta": {
"required": true,
"label": "Author"
}
},
"categoryId": {
"type": "string",
"refTarget": "categories",
"meta": {
"label": "Category"
}
}
}
By default, refTarget values are prefixed with the current layer name. For example, in the shop layer, "refTarget": "categories" becomes shopCategories.
To reference a collection outside your layer or an existing table (like a shared users table), prefix the collection name with a colon (:):
{
"authorId": {
"type": "string",
"refTarget": ":users",
"meta": {
"label": "Author"
}
}
}
This generates collection="users" instead of collection="shopUsers", allowing you to reference global collections or existing database tables.
Use cases:
users table across multiple layersteams or organizationsWhen working with reference fields:
For more details, see Working with Relations.
Select dropdown fields provide a fixed set of options for the user to choose from. Use these when you have a predefined list of values like status, type, or category.
For fields with a known set of options, use meta.options with displayAs: "optionsSelect":
{
"status": {
"type": "string",
"meta": {
"label": "Status",
"options": ["draft", "published", "archived"],
"displayAs": "optionsSelect",
"required": true
}
},
"triggerType": {
"type": "string",
"meta": {
"label": "Trigger Type",
"options": ["booking_created", "reminder_before", "booking_cancelled"],
"displayAs": "optionsSelect"
}
}
}
The generator automatically:
<USelect> component in the form"booking_created" → "Booking Created")"draft" → "Draft""in_progress" → "In Progress""follow_up_after" → "Follow Up After"For options stored in another collection (admin-configurable), use optionsCollection and optionsField:
{
"category": {
"type": "string",
"meta": {
"label": "Category",
"displayAs": "optionsSelect",
"optionsCollection": "settings",
"optionsField": "categories",
"creatable": true
}
}
}
This generates a CroutonFormOptionsSelect component that:
creatable: true)| Pattern | Use When |
|---|---|
Static Options (meta.options) | Fixed values that rarely change (status, type) |
Database Options (optionsCollection) | Admin-configurable values that can be added/edited |
Reference Field (refTarget) | Options are full records in another collection |
Repeater fields allow you to store and manage arrays of structured data without creating separate database tables. They're perfect for time slots, contact information, price tiers, or any scenario where you need multiple items of the same type.
{
"slots": {
"type": "repeater",
"meta": {
"label": "Available Time Slots",
"repeaterComponent": "Slot",
"addLabel": "Add Time Slot",
"sortable": true,
"area": "main"
}
}
}
The meta object for repeater fields supports these properties:
repeaterComponent (required) - Name of the component that renders each itemaddLabel (optional) - Button text for adding items (default: "Add Item")sortable (optional) - Enable drag-to-reorder functionality (default: true)label (optional) - Form field labelarea (optional) - Form area placementWhen you define a repeater field, the generator automatically creates:
CroutonRepeater with add/remove/reorder functionalityz.array(z.any()).optional() for type safety[]The auto-generated placeholder component is fully functional but basic. You'll want to customize it to match your data structure.
Location convention:
layers/[layer]/collections/[collection]/app/components/[ComponentName].vue
Example: Slot.vue
<script setup lang="ts">
import { nanoid } from 'nanoid'
interface TimeSlot {
id: string
label: string
startTime: string
endTime: string
}
const props = defineProps<{
modelValue: TimeSlot
}>()
const emit = defineEmits<{
'update:modelValue': [value: TimeSlot]
}>()
// Initialize with defaults if empty
const localValue = computed({
get: () => props.modelValue || {
id: nanoid(),
label: '',
startTime: '09:00',
endTime: '17:00'
},
set: (val) => emit('update:modelValue', val)
})
</script>
<template>
<div class="grid grid-cols-4 gap-4">
<UFormField label="ID" name="id">
<UInput
v-model="localValue.id"
disabled
class="bg-gray-50"
/>
</UFormField>
<UFormField label="Label" name="label">
<UInput v-model="localValue.label" />
</UFormField>
<UFormField label="Start Time" name="startTime">
<UInput
v-model="localValue.startTime"
type="time"
/>
</UFormField>
<UFormField label="End Time" name="endTime">
<UInput
v-model="localValue.endTime"
type="time"
/>
</UFormField>
</div>
</template>
Your item component must:
modelValue prop with the item dataupdate:modelValue when data changesv-model on form fieldsThe repeater field provides:
sortable: true)Time Slots (Bookings)
{
"slots": {
"type": "repeater",
"meta": {
"label": "Available Time Slots",
"repeaterComponent": "TimeSlot",
"addLabel": "Add Slot"
}
}
}
Contact Persons
{
"contacts": {
"type": "repeater",
"meta": {
"label": "Contact Persons",
"repeaterComponent": "ContactPerson",
"addLabel": "Add Contact"
}
}
}
Price Tiers
{
"priceTiers": {
"type": "repeater",
"meta": {
"label": "Pricing Tiers",
"repeaterComponent": "PriceTier",
"addLabel": "Add Tier",
"sortable": false
}
}
}
Social Media Links
{
"socialLinks": {
"type": "repeater",
"meta": {
"label": "Social Media",
"repeaterComponent": "SocialLink",
"addLabel": "Add Link"
}
}
}
Repeater data is stored as JSON in the database:
{
"slots": [
{
"id": "V1StGXR8_Z5jdHi6B-myT",
"label": "Morning",
"startTime": "09:00",
"endTime": "12:00"
},
{
"id": "k3Rl9mPw_Q7xYvNc4-abZ",
"label": "Afternoon",
"startTime": "13:00",
"endTime": "17:00"
}
]
}
nanoid(), providing collision-resistant identifiers that remain stable through reordering and editing. These IDs are more reliable than timestamps for rapid user interactions.Dependent fields create dynamic form inputs that load data from a referenced collection and display it conditionally. This is perfect for scenarios like selecting time slots from a location, choosing sizes from a product, or picking rooms from a building.
{
"location": {
"type": "string",
"refTarget": "locations",
"meta": {
"required": true,
"label": "Location",
"area": "main"
}
},
"slot": {
"type": "number",
"meta": {
"required": true,
"label": "Time Slot",
"area": "main",
"dependsOn": "location",
"dependsOnField": "slots",
"dependsOnCollection": "locations",
"displayAs": "slotButtonGroup"
}
}
}
The meta object for dependent fields supports these properties:
dependsOn (required) - Name of the field to watch for changesdependsOnField (required) - Field name in the referenced record to loaddependsOnCollection (required) - Collection to fetch data fromdisplayAs (required) - Display mode for the field (currently supports: slotButtonGroup)When you define a dependent field, the generator automatically creates:
useFetch with watch on the dependent fielddisplayAs: "slotButtonGroup")Displays options as a visual button group, perfect for time slots or preset choices:
<UButtonGroup class="w-full flex-wrap">
<UButton
v-for="option in locationData.slots"
:key="option.id"
:variant="state.slot === option.id ? 'solid' : 'outline'"
@click="state.slot = option.id"
class="flex-1 min-w-[200px]"
>
<div class="flex flex-col items-center py-2">
<span class="font-medium">{{ option.label }}</span>
<span class="text-xs opacity-75">{{ option.startTime }} - {{ option.endTime }}</span>
</div>
</UButton>
</UButtonGroup>
Location Schema (location-schema.json):
{
"title": {
"type": "string",
"meta": {
"required": true,
"label": "Location Name"
}
},
"slots": {
"type": "repeater",
"meta": {
"label": "Available Time Slots",
"repeaterComponent": "Slot",
"addLabel": "Add Time Slot"
}
}
}
Booking Schema (booking-schema.json):
{
"location": {
"type": "string",
"refTarget": "locations",
"meta": {
"required": true,
"label": "Location"
}
},
"date": {
"type": "date",
"meta": {
"required": true,
"label": "Booking Date"
}
},
"slot": {
"type": "number",
"meta": {
"required": true,
"label": "Time Slot",
"dependsOn": "location",
"dependsOnField": "slots",
"dependsOnCollection": "locations",
"displayAs": "slotButtonGroup"
}
}
}
Generated Form Behavior:
The generator creates this form code automatically:
<script setup lang="ts">
// ... existing setup code ...
const state = ref<BookingFormData>(initialValues)
// Auto-generated dependent field logic
const route = useRoute()
const teamId = computed(() => route.params.teamId)
// Fetch location data when location is selected
const { data: locationData } = await useFetch(() =>
state.value.location
? `/api/teams/${teamId}/bookingsLocations/${state.value.location}`
: null
, {
watch: [() => state.value.location],
immediate: false
})
// Reset slot when location changes
watch(() => state.value.location, () => {
state.value.slot = 0
})
</script>
<template>
<UFormField label="Time Slot" name="slot">
<div v-if="!state.location" class="text-gray-400 text-sm">
Please select location first
</div>
<UButtonGroup v-else-if="locationData && locationData.slots && locationData.slots.length > 0" class="w-full flex-wrap">
<UButton
v-for="option in locationData.slots"
:key="option.id"
:variant="state.slot === option.id ? 'solid' : 'outline'"
@click="state.slot = option.id"
class="flex-1 min-w-[200px]"
>
<div class="flex flex-col items-center py-2">
<span class="font-medium">{{ option.label }}</span>
<span class="text-xs opacity-75">{{ option.startTime }} - {{ option.endTime }}</span>
</div>
</UButton>
</UButtonGroup>
<div v-else class="text-gray-400 text-sm">
No slots configured for this location
</div>
</UFormField>
</template>
Time Slot Selection
{
"slot": {
"type": "number",
"meta": {
"dependsOn": "location",
"dependsOnField": "slots",
"dependsOnCollection": "locations",
"displayAs": "slotButtonGroup"
}
}
}
Product Sizes
{
"size": {
"type": "string",
"meta": {
"dependsOn": "product",
"dependsOnField": "availableSizes",
"dependsOnCollection": "products",
"displayAs": "slotButtonGroup"
}
}
}
Building Rooms
{
"room": {
"type": "number",
"meta": {
"dependsOn": "building",
"dependsOnField": "rooms",
"dependsOnCollection": "buildings",
"displayAs": "slotButtonGroup"
}
}
}
dependsOnField) should contain an array of objects with at least an id property. For slotButtonGroup display, objects should also have label, startTime, and endTime properties.v-if) when you only need to show/hide fields based on local state without data fetching.Fields can include a meta object with additional configuration:
{
"title": {
"type": "string",
"meta": {
"required": true,
"label": "Article Title",
"maxLength": 200,
"component": "CustomInput",
"area": "main"
}
}
}
You can add metadata properties like required for validation, label for human-readable form labels, maxLength for string length limits, component to specify a custom input component, readOnly to display reference fields as non-editable cards (useful for audit fields), area to logically group fields for form layout, group to create tabbed groups when multiple main groups exist, nullable to generate .nullish() instead of .optional() in the Zod schema, and translatable to mark fields for i18n support.
Mark fields that should support multiple languages with translatable: true:
{
"title": {
"type": "string",
"meta": {
"required": true,
"translatable": true
}
},
"description": {
"type": "text",
"meta": {
"translatable": true
}
},
"sku": {
"type": "string"
}
}
When a field has translatable: true:
CroutonI18nInput component for multi-language editingtranslations JSON field on the entity@fyit/crouton-i18n package is requiredtranslations.collections in your crouton.config.js, but field-level is more explicit and self-documenting.The area property lets you organize fields into logical sections. You might use main for primary content, sidebar for secondary metadata like status and categories, meta for SEO and publishing info, or advanced for advanced options. Currently all fields render in a single list regardless of area, but this property sets up the infrastructure for future layout features where fields can be organized into columns, tabs, or sections.
{
"title": {
"type": "string",
"meta": {
"area": "main" // Primary content area
}
},
"status": {
"type": "string",
"meta": {
"area": "sidebar" // Sidebar metadata
}
},
"publishedAt": {
"type": "date",
"meta": {
"area": "meta" // SEO/publishing info
}
}
}
Here's a complete product schema with various field types and metadata:
{
"name": {
"type": "string",
"meta": {
"required": true,
"label": "Product Name",
"maxLength": 200,
"area": "main"
}
},
"description": {
"type": "text",
"meta": {
"label": "Description",
"area": "main"
}
},
"price": {
"type": "decimal",
"meta": {
"required": true,
"default": 0,
"label": "Price",
"area": "sidebar"
}
},
"priceTiers": {
"type": "repeater",
"meta": {
"label": "Volume Pricing Tiers",
"repeaterComponent": "PriceTier",
"addLabel": "Add Tier",
"area": "main"
}
},
"inStock": {
"type": "boolean",
"meta": {
"default": true,
"label": "In Stock",
"area": "sidebar"
}
},
"sku": {
"type": "string",
"meta": {
"required": true,
"label": "SKU",
"maxLength": 50,
"area": "meta"
}
},
"publishedAt": {
"type": "date",
"meta": {
"label": "Publish Date",
"area": "meta"
}
}
}
Use descriptive names like publishedAt instead of abbreviations like pub. Set sensible defaults for fields when appropriate. Mark fields as required when they're necessary for your data model. Choose the right types—use decimal for money, text for long descriptions, and string for short labels. Remember that properties like required, default, and translatable go inside the meta object.
// Good practices
{
"publishedAt": {
"type": "date",
"meta": { "label": "Published At" }
},
"status": {
"type": "string",
"meta": { "default": "draft" }
},
"email": {
"type": "string",
"meta": { "required": true }
},
"price": {
"type": "decimal",
"meta": { "label": "Price" }
},
"description": {
"type": "text",
"meta": { "label": "Description" }
}
}