The @fyit/crouton-assets package provides a centralized media library system for managing images and files across your Nuxt Crouton application.
Nuxt Crouton offers flexibility in how you handle file uploads:
1. Simple Direct Uploads (Base Package)
CroutonImageUpload component2. Full Asset Management (Assets Package)
@fyit/crouton installed@nuxthub/core with blob storage enabledpnpm add @fyit/crouton-assets
Add the assets layer to your nuxt.config.ts:
export default defineNuxtConfig({
extends: [
'@fyit/crouton',
'@fyit/crouton-assets' // Add this
],
hub: {
blob: true // Required for file storage
}
})
Use the crouton CLI to create the assets collection:
crouton-generate core assets \
--fields-file=node_modules/@fyit/crouton-assets/assets-schema.json \
--dialect=sqlite
This creates:
layers/core/collections/assets/
├── app/
│ └── components/
│ ├── _Form.vue # CRUD form
│ └── List.vue # Asset list view
├── types.ts # Type exports
├── server/
│ ├── database/
│ │ └── schema.ts # Drizzle database schema
│ └── api/
│ └── [...].ts # CRUD endpoints
The generated assets collection includes:
{
id: string // Unique identifier
teamId: string // Team/organization ownership
userId: string // User who uploaded
filename: string // Original filename
pathname: string // Blob storage path
contentType: string // MIME type (image/jpeg, etc)
size: number // File size in bytes
alt: string // Alt text for accessibility
uploadedAt: Date // Upload timestamp
createdAt: Date // Record created
updatedAt: Date // Record updated
updatedBy: string // Last modifier
}
Reference assets in your collection schemas:
{
"imageId": {
"type": "string",
"refTarget": "assets",
"meta": {
"component": "CroutonAssetsPicker",
"label": "Featured Image",
"area": "main"
}
}
}
This automatically generates:
refTarget points to a collection named assets, images, files, or media, the generator will automatically use CroutonAssetsPicker even without specifying the component in meta!Browse and select from your asset library:
<template>
<UFormField label="Product Image" name="imageId">
<CroutonAssetsPicker v-model="state.imageId" />
</UFormField>
</template>
<script setup lang="ts">
const state = ref({
imageId: ''
})
</script>
Features:
Upload files with metadata:
<template>
<UModal v-model="showUploader">
<template #content="{ close }">
<div class="p-6">
<h3 class="text-lg font-semibold mb-4">Upload Asset</h3>
<CroutonAssetsUploader @uploaded="handleUploaded(close)" />
</div>
</template>
</UModal>
</template>
<script setup lang="ts">
const showUploader = ref(false)
const handleUploaded = async (close: () => void, assetId: string) => {
console.log('New asset:', assetId)
// Refresh your asset list or select the new asset
close()
}
</script>
Use the composable for custom upload flows:
<script setup lang="ts">
const { uploadAsset, uploading, error } = useAssetUpload()
const handleDrop = async (event: DragEvent) => {
const file = event.dataTransfer?.files[0]
if (!file) return
try {
const asset = await uploadAsset(file, {
alt: 'Drag-and-drop upload',
filename: file.name
})
console.log('Uploaded:', asset.id)
} catch (err) {
console.error('Upload failed:', error.value)
}
}
</script>
<template>
<div
@drop.prevent="handleDrop"
@dragover.prevent
class="border-2 border-dashed p-8 text-center"
>
<p v-if="!uploading">Drop files here to upload</p>
<p v-else>Uploading...</p>
</div>
</template>
nuxt-crouton)/api/upload-image endpoint/images/[pathname] serving routenuxt-crouton-assets)CroutonAssetsPicker componentCroutonAssetsUploader componentuseAssetUpload() composablelayers/core/collections/assets/1. User selects file
↓
2. File uploaded to NuxtHub blob storage
→ POST /api/upload-image
→ Returns { pathname, contentType, size, filename }
↓
3. Asset record created in database
→ POST /api/teams/[id]/assets
→ Stores metadata + pathname
↓
4. Asset available in library
→ GET /api/teams/[id]/assets
→ Returns list with metadata
Browser requests: /images/[pathname]
↓
GET /images/[pathname] route
↓
Fetches from blob storage
↓
Serves file to browser
// Schema: shopProducts.json
{
"name": {
"type": "string",
"meta": { "required": true }
},
"imageId": {
"type": "string",
"refTarget": "assets",
"meta": {
"label": "Product Image"
// No need to specify component - auto-detected!
}
}
}
Generated form automatically includes asset picker thanks to auto-detection.
// Schema: blogPosts.json
{
"title": {
"type": "string",
"meta": { "required": true }
},
"featuredImageId": {
"type": "string",
"refTarget": "assets",
"meta": {
"label": "Featured Image"
// Auto-detected as asset picker
}
},
"galleryImageIds": {
"type": "json",
"meta": {
"label": "Gallery Images",
"component": "MultiAssetPicker" // Custom component
}
}
}
You'll need to create a custom MultiAssetPicker component for multiple selections.
For simple avatar uploads without the asset library:
<template>
<CroutonUsersAvatarUpload
v-model="avatarUrl"
@file-selected="handleAvatarUpload"
/>
</template>
<script setup lang="ts">
const avatarUrl = ref('/default-avatar.png')
const handleAvatarUpload = async (file: File | null) => {
if (!file) return
const formData = new FormData()
formData.append('image', file)
const result = await $fetch('/api/upload-image', {
method: 'POST',
body: formData
})
// Returns { pathname, contentType, size, filename }
avatarUrl.value = `/images/${result.pathname}`
// Update user profile
await $fetch(`/api/users/${user.id}`, {
method: 'PATCH',
body: { avatar: pathname }
})
}
</script>
Upload multiple files programmatically:
<script setup lang="ts">
const { uploadAssets, uploading } = useAssetUpload()
const handleBatchUpload = async (files: File[]) => {
const assets = await uploadAssets(files, {
alt: 'Batch uploaded image'
})
console.log(`Uploaded ${assets.length} files`)
return assets.map(a => a.id)
}
</script>
<template>
<input
type="file"
multiple
@change="handleBatchUpload(Array.from($event.target.files))"
/>
<div v-if="uploading">Uploading {{ files.length }} files...</div>
</template>
The generator automatically creates CardMini components for asset references:
<template>
<CroutonCollection
:rows="products"
collection="shopProducts"
>
<!-- Auto-generated for asset references -->
<template #imageId-cell="{ row }">
<CardMini
v-if="row.original.imageId"
:id="row.original.imageId"
collection="assets"
/>
</template>
</CroutonCollection>
</template>
Fetch and display asset details:
<script setup lang="ts">
const { data: product } = await useFetch('/api/teams/123/shopProducts/456')
// Fetch referenced asset
const { data: asset } = await useFetch(
`/api/teams/123/assets/${product.value?.imageId}`,
{ watch: [() => product.value?.imageId] }
)
const imageUrl = computed(() =>
asset.value?.pathname ? `/images/${asset.value.pathname}` : '/placeholder.png'
)
</script>
<template>
<div>
<img
:src="imageUrl"
:alt="asset?.alt || 'Product image'"
class="w-full h-64 object-cover rounded-lg"
/>
<p class="text-sm text-gray-500 mt-2">
{{ asset?.filename }} ({{ formatFileSize(asset?.size) }})
</p>
</div>
</template>
The asset picker includes built-in search:
<!-- In AssetPicker.vue -->
<UInput
v-model="searchQuery"
icon="i-lucide-search"
placeholder="Search assets..."
/>
<!-- Filtered results -->
<div v-for="asset in filteredAssets" :key="asset.id">
<img :src="`/images/${asset.pathname}`" :alt="asset.alt" />
</div>
Search matches:
Always provide descriptive alt text:
await uploadAsset(file, {
alt: 'Red Nike sneakers on white background, side view'
})
Good alt text:
Use descriptive filenames:
await uploadAsset(file, {
filename: 'red-nike-air-max-90-side.jpg',
alt: 'Red Nike Air Max 90, side view'
})
Assets are automatically scoped to teams:
// Assets are team-scoped via teamId
GET /api/teams/team-123/assets // Returns only team-123's assets
GET /api/teams/team-456/assets // Returns only team-456's assets
Reference the same asset multiple times:
// Multiple products can share the same asset
{
product1: { imageId: 'asset-123' },
product2: { imageId: 'asset-123' },
product3: { imageId: 'asset-456' }
}
Benefits:
You can generate multiple asset collections for different purposes:
# General assets
crouton-generate core assets --fields-file=assets-schema.json
# Product-specific images
crouton-generate products productImages --fields-file=product-images-schema.json
# User avatars
crouton-generate users avatars --fields-file=avatars-schema.json
Then specify the collection when using components:
<CroutonAssetsPicker v-model="state.imageId" collection="productImages" />
// nuxt.config.ts
export default defineNuxtConfig({
hub: {
blob: true
}
})
ls layers/core/collections/assets
// Asset picker needs team in route params
GET /teams/:team/...
console.log('Pathname:', asset.pathname)
console.log('Full URL:', `/images/${asset.pathname}`)
# In NuxtHub dashboard, verify file exists
curl http://localhost:3000/images/[pathname]
If you're currently storing URLs directly, you can migrate to the asset system:
{
- "imageUrl": { "type": "string" }
+ "imageId": {
+ "type": "string",
+ "refTarget": "assets"
+ // Component auto-detected as CroutonAssetsPicker
+ }
}
Create a migration script:
// scripts/migrate-to-assets.ts
import { db } from '~/server/db'
const products = await db.select().from(shopProducts)
for (const product of products) {
if (!product.imageUrl) continue
// Extract pathname from URL
const pathname = product.imageUrl.replace('/images/', '')
// Create asset record
const asset = await db.insert(assets).values({
id: generateId(),
teamId: product.teamId,
userId: product.userId,
filename: pathname.split('/').pop(),
pathname,
contentType: 'image/jpeg', // Detect from file
size: 0, // Could fetch from blob
uploadedAt: product.createdAt
})
// Update product reference
await db
.update(shopProducts)
.set({ imageId: asset.id })
.where(eq(shopProducts.id, product.id))
}
crouton-generate products shopProducts --overwrite