Customize how data is displayed in your tables by adding computed columns, custom renderers, and custom components.
Columns are defined in your composable and control how data appears in table views.
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
}
}
Add virtual columns that don't exist in your data but are computed on the fly.
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'
}
]
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)}%`
}
}
]
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()
}
}
]
Use custom Vue components for complex column rendering.
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>
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>
Use computed columns for reactive behavior.
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 }
}
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 }
}
Some default columns use custom rendering automatically:
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:
useMetadata: true in the generator:hide-default-columns="{ updatedBy: true }"updatedBy to your schema JSON files - it's metadata, not a business field. The generator adds it automatically.Show data from related collections.
useCollectionQuery patterns, see Querying Data.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 }
}
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'
}
]
Enable sorting on columns:
const columns = [
{
accessorKey: 'name',
header: 'Name',
sortable: true
},
{
accessorKey: 'price',
header: 'Price',
sortable: true
},
{
accessorKey: 'createdAt',
header: 'Created',
sortable: true
}
]
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)
}
]
// 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)
}
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 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 }
}