Here are the recommended patterns and practices for building maintainable, performant applications with Nuxt Crouton.
After generating a collection, the best way to build your app is:
_Form.vue and List.vue directly. These are designed to work seamlessly with Crouton's composables and endpoints.useCollectionQuery and useCollectionMutation for data operations. They handle caching, team-scoping, and error handling automatically.Use Nuxt layers to organize your collections by business domain rather than technical function. This approach provides clear boundaries between domains, makes code easier to maintain and test, allows independent deployment of layers, and makes them reusable across projects.
For a comprehensive guide on architecture and domain-driven design, see the Architecture documentation.
Use plural names like products, orders, and posts. For multi-word names, use camelCase like blogPosts or orderItems. Be descriptive—choose userProfiles over just profiles. Avoid singular names, abbreviations, or generic names like items or data.
Use title as the display field: Every collection that needs a human-readable label should use title as the primary display field. This is required for CroutonFormReferenceSelect dropdowns to work correctly.
{
"title": { "type": "string", "meta": { "required": true, "label": "Name" } }
}
name for display fields—use title consistently across all collections. The CroutonFormReferenceSelect component defaults to labelKey="title".Keep generated files clean and customizations separate:
layers/shop/
├── components/
│ └── products/
│ ├── _Form.vue # Generated (customize freely)
│ ├── List.vue # Generated (customize freely)
│ └── ProductCard.vue # Your custom component
│
├── composables/
│ ├── useProducts.ts # Generated (customize freely)
│ └── useProductHelpers.ts # Your custom composable
│
└── utils/
└── productFormatters.ts # Your utilities
Use useCollectionMutation() for:
Use useCroutonMutate() for:
<script setup lang="ts">
// ✅ Good for quick actions
const { mutate } = useCroutonMutate()
const toggleFeatured = async (product: Product) => {
await mutate('update', 'shopProducts', {
id: product.id,
featured: !product.featured
})
}
</script>
useCollectionQuery patterns, see Querying Data.Use TypeScript generics for type safety:
// ❌ Bad - no type safety
const { items } = await useCollectionQuery('shopProducts')
// ✅ Good - full type safety
import type { ShopProduct } from '~/layers/shop/types/products'
const { items } = await useCollectionQuery<ShopProduct>('shopProducts')
Always provide feedback to users:
<script setup lang="ts">
const { items, pending, error, refresh } = await useCollectionQuery('shopProducts')
</script>
<template>
<div v-if="pending" class="flex justify-center p-8">
<UIcon name="i-lucide-refresh-cw" class="animate-spin" />
</div>
<div v-else-if="error" class="p-4 bg-red-50 text-red-600">
Error loading products.
<UButton @click="refresh" size="sm">Retry</UButton>
</div>
<div v-else-if="items.length === 0" class="p-8 text-center text-gray-500">
No products found.
</div>
<CroutonCollection v-else :rows="items" :columns="columns" />
</template>
Make queries reactive to parameter changes. See Querying Data for complete examples of reactive query patterns.
Centralize validation logic using Zod:
// layers/shop/composables/useProducts.ts
import { z } from 'zod'
export function useShopProducts() {
const schema = z.object({
name: z.string().min(1, 'Name is required'),
price: z.number().min(0, 'Price must be positive'),
sku: z.string().regex(/^[A-Z]{3}-\d{4}$/, 'SKU format: ABC-1234')
})
return { schema }
}
Don't rely on form validation alone:
<script setup lang="ts">
const { schema } = useShopProducts()
const state = ref({ name: '', price: 0 })
const handleSubmit = async () => {
// ✅ Good - validate before API call
const result = schema.safeParse(state.value)
if (!result.success) {
// Show errors
return
}
await create(result.data)
}
</script>
.safeParse() and validation methods in the Zod documentation.Make validation errors helpful:
// ❌ Bad
z.string().min(1)
// ✅ Good
z.string().min(1, 'Product name is required')
// ✅ Better
z.string()
.min(1, 'Product name is required')
.max(100, 'Product name must be less than 100 characters')
Start with foreign keys:
// ✅ Start here
const product = {
id: '123',
categoryId: 'cat-456' // Just store the ID
}
// Query when needed
const category = await db.select()
.from(categories)
.where(eq(categories.id, product.categoryId))
Add Drizzle relations only if needed:
// ✅ Optimize when you have performance issues
export const productsRelations = relations(products, ({ one }) => ({
category: one(categories, {
fields: [products.categoryId],
references: [categories.id]
})
}))
Always handle nullable relations:
<script setup lang="ts">
const categoryMap = computed(() =>
Object.fromEntries(categories.value.map(c => [c.id, c]))
)
const columns = [
{
key: 'category',
label: 'Category',
// ✅ Good - handles missing category
render: (row) => categoryMap.value[row.categoryId]?.name || 'Uncategorized'
}
]
</script>
Use server-side joins for lists:
// ❌ Bad - N+1 query problem
// 1 query for products + 100 queries for categories
const products = await db.select().from(products)
for (const product of products) {
product.category = await db.select()
.from(categories)
.where(eq(categories.id, product.categoryId))
}
// ✅ Good - single query
const products = await db.query.products.findMany({
with: { category: true }
})
Don't wait for performance issues. Implement pagination from the start for better performance.
Move filtering to the backend:
// server/api/teams/[team]/products/index.get.ts
export default defineEventHandler(async (event) => {
const query = getQuery(event)
const { search, category, inStock } = query
let dbQuery = db.select().from(products)
// ✅ Filter in database
if (search) {
dbQuery = dbQuery.where(like(products.name, `%${search}%`))
}
if (category) {
dbQuery = dbQuery.where(eq(products.categoryId, category))
}
if (inStock !== undefined) {
dbQuery = dbQuery.where(eq(products.inStock, inStock === 'true'))
}
return dbQuery
})
Add database indexes for frequently queried fields:
// layers/shop/server/database/schema.ts
import { sqliteTable, text, real, index } from 'drizzle-orm/sqlite-core'
export const shopProducts = sqliteTable('shop_products', {
id: text('id').primaryKey(),
teamId: text('teamId').notNull(),
categoryId: text('categoryId'),
name: text('name').notNull(),
price: real('price')
}, (table) => ({
// ✅ Add indexes for performance
teamIdx: index('products_team_idx').on(table.teamId),
categoryIdx: index('products_category_idx').on(table.categoryId),
nameIdx: index('products_name_idx').on(table.name)
}))
Don't be afraid to customize:
<!-- layers/shop/components/products/_Form.vue -->
<script setup lang="ts">
// ✅ Keep generated logic
const props = defineProps<ShopProductsFormProps>()
const { create, update } = useCollectionMutation(props.collection)
// ✅ Add your customizations
const uploadingImage = ref(false)
const imagePreview = ref<string | null>(null)
const handleImageUpload = async (file: File) => {
uploadingImage.value = true
const url = await uploadToCloudinary(file)
state.value.imageUrl = url
uploadingImage.value = false
}
</script>
<template>
<UForm @submit="handleSubmit">
<!-- Generated fields -->
<UFormField label="Name" name="name">
<UInput v-model="state.name" />
</UFormField>
<!-- Your custom fields -->
<UFormField label="Product Image" name="imageUrl">
<img v-if="state.imageUrl" :src="state.imageUrl" />
<UButton @click="triggerUpload" :loading="uploadingImage">
Upload Image
</UButton>
</UFormField>
<CroutonFormActionButton :action="action" :loading="loading" />
</UForm>
</template>
Add comments to explain customizations:
<script setup lang="ts">
// GENERATED: Base form setup
const props = defineProps<ShopProductsFormProps>()
const { create, update } = useCollectionMutation(props.collection)
// CUSTOM: Image upload functionality
// Uses Cloudinary for storage, see docs/image-upload.md
const uploadingImage = ref(false)
const handleImageUpload = async (file: File) => {
// ... upload logic
}
// CUSTOM: Category management
// Allows creating new categories inline
const showNewCategoryForm = ref(false)
</script>
Before regenerating:
cp layers/shop/components/products/_Form.vue layers/shop/components/products/_Form.vue.backup
npx crouton-generate shop products --fields-file schema.json --force
Ensure all operations work:
// tests/products.test.ts
import { describe, it, expect } from 'vitest'
describe('Product CRUD', () => {
it('creates product', async () => {
const { create } = useCollectionMutation('shopProducts')
const product = await create({ name: 'Test', price: 10 })
expect(product.id).toBeDefined()
})
it('updates product', async () => {
const { update } = useCollectionMutation('shopProducts')
const updated = await update('123', { price: 20 })
expect(updated.price).toBe(20)
})
it('deletes product', async () => {
const { deleteItems } = useCollectionMutation('shopProducts')
await deleteItems(['123'])
// Verify deletion
})
})
Ensure validation catches errors:
describe('Product validation', () => {
it('requires name', () => {
const { schema } = useShopProducts()
const result = schema.safeParse({ price: 10 })
expect(result.success).toBe(false)
})
it('validates price is positive', () => {
const { schema } = useShopProducts()
const result = schema.safeParse({ name: 'Test', price: -5 })
expect(result.success).toBe(false)
})
})
Never trust client-side validation alone:
// server/api/teams/[team]/products/index.post.ts
import { z } from 'zod'
const schema = z.object({
name: z.string().min(1).max(200),
price: z.number().min(0).max(1000000),
teamId: z.string()
})
export default defineEventHandler(async (event) => {
const body = await readBody(event)
// ✅ Validate on server
const result = schema.safeParse(body)
if (!result.success) {
throw createError({
status: 400,
statusText: 'Invalid data'
})
}
return await db.insert(products).values(result.data)
})
Always filter by team ID:
// ✅ Good - team-scoped
export default defineEventHandler(async (event) => {
const teamId = getRouterParam(event, 'team')
return await db.select()
.from(products)
.where(eq(products.teamId, teamId)) // ← Always filter by team
})
Never commit sensitive data:
// ❌ Bad
const apiKey = 'sk_live_123abc'
// ✅ Good
const apiKey = process.env.API_KEY
Add to .gitignore:
.env
.env.local
secrets/