Customizing Generated Code

Custom Columns

Customize table columns with computed values, custom rendering, and custom components

Customize how data is displayed in your tables by adding computed columns, custom renderers, and custom components.

Add Custom Columns

Columns are defined in your composable and control how data appears in table views.

Basic Column Definition

TanStack Table format: Crouton uses TanStack Table under the hood. Column definitions use accessorKey and header instead of key and label. The examples below use the current TanStack Table API format.
// layers/shop/composables/useProducts.ts
export function useShopProducts() {
  const columns = [
    { accessorKey: 'name', header: 'Name' },
    { accessorKey: 'price', header: 'Price' },
    { accessorKey: 'inStock', header: 'In Stock' }
  ]

  return {
    columns,
    // ...other exports
  }
}

Computed Columns

Add virtual columns that don't exist in your data but are computed on the fly.

Using Cell Function

const columns = [
  { accessorKey: 'name', header: 'Name' },
  { accessorKey: 'price', header: 'Price' },

  // Add computed column
  {
    accessorKey: 'status',
    header: 'Status',
    cell: ({ row }) => row.original.inStock ? 'Available' : 'Out of Stock'
  }
]

Computing Values

const columns = [
  { accessorKey: 'name', header: 'Product' },
  { accessorKey: 'price', header: 'Price' },
  { accessorKey: 'cost', header: 'Cost' },

  // Calculate profit
  {
    accessorKey: 'profit',
    header: 'Profit',
    cell: ({ row }) => row.original.price - row.original.cost
  },

  // Calculate margin percentage
  {
    accessorKey: 'margin',
    header: 'Margin %',
    cell: ({ row }) => {
      const margin = ((row.original.price - row.original.cost) / row.original.price) * 100
      return `${margin.toFixed(2)}%`
    }
  }
]

Formatting Values

const columns = [
  {
    accessorKey: 'price',
    header: 'Price',
    cell: ({ row }) => {
      // Format as currency
      return new Intl.NumberFormat('en-US', {
        style: 'currency',
        currency: 'USD'
      }).format(row.original.price)
    }
  },

  {
    accessorKey: 'createdAt',
    header: 'Created',
    cell: ({ row }) => {
      // Format date
      return new Intl.DateTimeFormat('en-US', {
        dateStyle: 'medium',
        timeStyle: 'short'
      }).format(new Date(row.original.createdAt))
    }
  },

  {
    accessorKey: 'quantity',
    header: 'Quantity',
    cell: ({ row }) => {
      // Add thousands separator
      return row.original.quantity.toLocaleString()
    }
  }
]

Custom Components

Use custom Vue components for complex column rendering.

Basic Component Column

const columns = [
  { accessorKey: 'name', header: 'Product' },
  { accessorKey: 'price', header: 'Price' },

  // Add custom component column using cell function
  {
    id: 'actions',
    header: '',
    cell: ({ row }) => h(resolveComponent('ProductActions'), { row: row.original })
  }
]

Then create the component:

<!-- layers/shop/components/products/ProductActions.vue -->
<script setup lang="ts">
defineProps<{
  row: any
}>()

const { open } = useCrouton()
</script>

<template>
  <div class="flex gap-2">
    <UButton size="xs" @click="open('update', 'shopProducts', [row.id])">
      Edit
    </UButton>
    <UButton size="xs" color="red" @click="open('delete', 'shopProducts', [row.id])">
      Delete
    </UButton>
  </div>
</template>

Badge Component

Display status badges:

const columns = [
  { accessorKey: 'name', header: 'Product' },
  {
    accessorKey: 'status',
    header: 'Status',
    cell: ({ row }) => h(resolveComponent('ProductStatus'), { row: row.original })
  }
]
<!-- layers/shop/components/products/ProductStatus.vue -->
<script setup lang="ts">
const props = defineProps<{
  row: any
}>()

const statusColor = computed(() => {
  switch (props.row.status) {
    case 'active': return 'green'
    case 'draft': return 'yellow'
    case 'archived': return 'gray'
    default: return 'gray'
  }
})
</script>

<template>
  <UBadge :color="statusColor">
    {{ row.status }}
  </UBadge>
</template>

Dynamic Columns

Use computed columns for reactive behavior.

Based on User Permissions

export function useShopProducts() {
  const { hasPermission } = useAuth()

  const columns = computed(() => [
    { accessorKey: 'name', header: 'Name' },
    { accessorKey: 'price', header: 'Price' },

    // Show cost only to admins
    ...(hasPermission('view_cost') ? [{
      accessorKey: 'cost',
      header: 'Cost'
    }] : []),

    // Show profit only to managers
    ...(hasPermission('view_profit') ? [{
      accessorKey: 'profit',
      header: 'Profit',
      cell: ({ row }) => row.original.price - row.original.cost
    }] : [])
  ])

  return { columns }
}

Based on Screen Size

export function useShopProducts() {
  const { width } = useWindowSize()
  const isMobile = computed(() => width.value < 768)

  const columns = computed(() => [
    { accessorKey: 'name', header: 'Name' },

    // Hide on mobile
    ...(!isMobile.value ? [
      { accessorKey: 'description', header: 'Description' },
      { accessorKey: 'category', header: 'Category' }
    ] : []),

    { accessorKey: 'price', header: 'Price' },
    {
      id: 'actions',
      header: '',
      cell: ({ row }) => h(resolveComponent('ProductActions'), { row: row.original })
    }
  ])

  return { columns }
}

Default Columns with Special Rendering

Some default columns use custom rendering automatically:

updatedBy Column

The updatedBy column automatically displays user information using a CardMini component:

<template>
  <CroutonCollection
    :rows="items"
    :columns="columns"
    collection="shopProducts"
    layout="list"
  />
  <!-- updatedBy column automatically shows user card -->
</template>

The updatedBy field:

  • Is automatically added to all collections when useMetadata: true in the generator
  • Links to the users collection
  • Displays with a CardMini component showing user details
  • Can be hidden with :hide-default-columns="{ updatedBy: true }"
Don't add updatedBy to your schema JSON files - it's metadata, not a business field. The generator adds it automatically.

Show data from related collections.

Query Examples: For complete useCollectionQuery patterns, see Querying Data.

Client-Side Lookup

export function useShopProducts() {
  const { items: categories } = await useCollectionQuery('shopCategories')

  // Create lookup map
  const categoryMap = computed(() =>
    Object.fromEntries(categories.value.map(c => [c.id, c]))
  )

  const columns = [
    { accessorKey: 'name', header: 'Product' },
    { accessorKey: 'price', header: 'Price' },

    // Look up category name
    {
      accessorKey: 'category',
      header: 'Category',
      cell: ({ row }) => categoryMap.value[row.original.categoryId]?.name || 'N/A'
    }
  ]

  return { columns }
}

Server-Side Join

If your API returns joined data:

const columns = [
  { accessorKey: 'name', header: 'Product' },
  { accessorKey: 'price', header: 'Price' },

  // Access nested data directly
  {
    accessorKey: 'category.name',
    header: 'Category'
  }
]

Sortable Columns

Enable sorting on columns:

const columns = [
  {
    accessorKey: 'name',
    header: 'Name',
    sortable: true
  },
  {
    accessorKey: 'price',
    header: 'Price',
    sortable: true
  },
  {
    accessorKey: 'createdAt',
    header: 'Created',
    sortable: true
  }
]

Column Alignment

Control text alignment using the cell function to wrap content with appropriate CSS classes:

const columns = [
  {
    accessorKey: 'name',
    header: 'Name'
    // Default left alignment — no extra styling needed
  },
  {
    accessorKey: 'price',
    header: 'Price',
    cell: ({ row }) => h('span', { class: 'text-right block' }, row.original.price)
  },
  {
    accessorKey: 'status',
    header: 'Status',
    cell: ({ row }) => h('span', { class: 'text-center block' }, row.original.status)
  }
]

Best Practices

Keep Cell Functions Simple

// Good: Simple, readable
{
  accessorKey: 'status',
  header: 'Status',
  cell: ({ row }) => row.original.inStock ? 'Available' : 'Sold Out'
}

// Bad: Complex logic in cell
{
  accessorKey: 'status',
  header: 'Status',
  cell: ({ row }) => {
    const now = new Date()
    const created = new Date(row.original.createdAt)
    const daysSince = (now - created) / (1000 * 60 * 60 * 24)
    if (row.original.featured && daysSince < 7) return 'New & Featured'
    if (row.original.featured) return 'Featured'
    if (daysSince < 7) return 'New'
    if (row.original.inStock) return 'Available'
    return 'Sold Out'
  }
}

// Better: Extract to a helper function
const getProductStatus = (product) => {
  const now = new Date()
  const created = new Date(product.createdAt)
  const daysSince = (now - created) / (1000 * 60 * 60 * 24)

  if (product.featured && daysSince < 7) return 'New & Featured'
  if (product.featured) return 'Featured'
  if (daysSince < 7) return 'New'
  if (product.inStock) return 'Available'
  return 'Sold Out'
}

{
  accessorKey: 'status',
  header: 'Status',
  cell: ({ row }) => getProductStatus(row.original)
}

Use Components for Complex UI

Move complex rendering to components:

// Instead of complex cell logic, use a Vue component
{
  id: 'actions',
  header: 'Actions',
  cell: ({ row }) => h(resolveComponent('ProductActions'), { row: row.original })
}

Cache Lookups

Cache expensive computations:

export function useShopProducts() {
  const { items: categories } = await useCollectionQuery('shopCategories')

  // Cache the lookup map
  const categoryMap = computed(() =>
    Object.fromEntries(categories.value.map(c => [c.id, c]))
  )

  // Reuse cached map in cell functions
  const columns = [
    {
      accessorKey: 'category',
      header: 'Category',
      cell: ({ row }) => categoryMap.value[row.original.categoryId]?.name || 'N/A'
    }
  ]

  return { columns }
}