Guides

Best Practices

Recommended patterns and practices for building with Nuxt Crouton

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:

  1. Use the built-in endpoints — Nuxt Crouton provides complete CRUD endpoints automatically. Don't create custom API routes unless you need custom logic.
  2. Customize the generated components — Edit List.vue, Form.vue, Card.vue, and Table.vue directly. These are designed to work seamlessly with Crouton's composables and endpoints.
  3. Extend via composables — Use useCollectionQuery and useCollectionMutation for data operations. They handle caching, team-scoping, and error handling automatically.
The generated components + built-in endpoints + Crouton composables form an integrated system. Replacing any part means losing that integration.

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" } }
}
Do NOT use 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

Mutation Methods: For complete API documentation and examples, see Mutation Composables.

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

Query Examples: For complete 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')
TypeScript with Vue: Learn more about TypeScript generics in Vue in the Vue TypeScript documentation.

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>
Zod Validation: Learn more about .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]
  })
}))
Drizzle Relations: Learn more about Drizzle ORM relations in the Drizzle documentation.

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.

Pagination Patterns: For complete pagination examples, see Querying Data.

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:

  1. Backup customizations:
cp layers/shop/components/products/Form.vue layers/shop/components/products/Form.vue.backup
  1. Regenerate:
npx crouton-generate shop products --fields-file schema.json --force
  1. 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/