Best Practices
Here are the recommended patterns and practices for building maintainable, performant applications with Nuxt Crouton.
The Recommended Workflow
After generating a collection, the best way to build your app is:
- Use the built-in endpoints — Nuxt Crouton provides complete CRUD endpoints automatically. Don't create custom API routes unless you need custom logic.
- Customize the generated components — Edit
List.vue,Form.vue,Card.vue, andTable.vuedirectly. These are designed to work seamlessly with Crouton's composables and endpoints. - Extend via composables — Use
useCollectionQueryanduseCollectionMutationfor data operations. They handle caching, team-scoping, and error handling automatically.
Code Organization
Use Layers for Domain Separation
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.
Collection Naming Conventions
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.
Field Naming Conventions
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".File Organization
Keep generated files clean and customizations separate:
layers/shop/
├── components/
│ └── products/
│ ├── List.vue # Generated (customize freely)
│ ├── Form.vue # Generated (customize freely)
│ ├── Table.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
Data Operations
Choose the Right Mutation Method
Use useCollectionMutation() for:
- Generated forms
- Repeated operations on the same collection
- Multi-step wizards
- Bulk operations
Use useCroutonMutate() for:
- Quick toggle buttons
- One-off actions
- Utility functions
- Different collections
<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>
Always Type Your Queries
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')
Handle Loading and Error States
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-heroicons-arrow-path" 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>
<CroutonList v-else :rows="items" :columns="columns" />
</template>
Use Computed Queries for Reactivity
Make queries reactive to parameter changes. See Querying Data for complete examples of reactive query patterns.
Forms and Validation
Keep Validation in Composables
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 }
}
Validate Before Submit
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.Provide Clear Error Messages
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')
Relations and Associations
Start Simple, Optimize Later
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]
})
}))
Handle Missing Relations
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>
Optimize N+1 Queries
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 }
})
Performance
Implement Pagination Early
Don't wait for performance issues. Implement pagination from the start for better performance.
Filter on the Server
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
})
Use Indexes
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)
}))
Customization
Own the Generated Code
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>
<CroutonButton :action="action" :loading="loading" />
</UForm>
</template>
Document Your Changes
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>
Regenerate Carefully
Before regenerating:
- Backup customizations:
cp layers/shop/components/products/Form.vue layers/shop/components/products/Form.vue.backup
- Regenerate:
npx crouton-generate shop products --fields-file schema.json --force
- Restore customizations:
- Compare files
- Merge custom logic
- Test thoroughly
Testing
Test CRUD Operations
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
})
})
Test Validation
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)
})
})
Security
Validate on Server
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({
statusCode: 400,
message: 'Invalid data'
})
}
return await db.insert(products).values(result.data)
})
Scope by Team
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
})
Don't Expose Secrets
Never commit sensitive data:
// ❌ Bad
const apiKey = 'sk_live_123abc'
// ✅ Good
const apiKey = process.env.API_KEY
Add to .gitignore:
.env
.env.local
secrets/
Related Resources
- API Reference - Detailed API docs
- Troubleshooting - Common issues
- Migration Guide - Upgrade guides
- Working with Data - Data operations