Asset Management
The @friendlyinternet/nuxt-crouton-assets package provides a centralized media library system for managing images and files across your Nuxt Crouton application.
Overview
Two Approaches to File Uploads
Nuxt Crouton offers flexibility in how you handle file uploads:
1. Simple Direct Uploads (Base Package)
- Store URLs directly in your database
- Quick and simple for basic needs
- Uses
CroutonImageUploadcomponent - No metadata tracking
2. Full Asset Management (Assets Package)
- Centralized media library
- Rich metadata (alt text, size, MIME type)
- Team-based ownership
- Search and browse capabilities
- Reuse assets across collections
Installation
Prerequisites
- Nuxt 4+
@friendlyinternet/nuxt-croutoninstalled@nuxthub/corewith blob storage enabled
Install the Package
pnpm add @friendlyinternet/nuxt-crouton-assets
Configure Nuxt
Add the assets layer to your nuxt.config.ts:
export default defineNuxtConfig({
extends: [
'@friendlyinternet/nuxt-crouton',
'@friendlyinternet/nuxt-crouton-assets' // Add this
],
hub: {
blob: true // Required for file storage
}
})
Setting Up the Assets Collection
Generate the Collection
Use the crouton generator to create the assets collection:
crouton-generate core assets \
--fields-file=node_modules/@friendlyinternet/nuxt-crouton-assets/assets-schema.json \
--dialect=sqlite
This creates:
layers/core/collections/assets/
├── Form.vue # CRUD form
├── List.vue # Asset list view
├── index.ts # API exports
├── schema.ts # Zod validation
├── drizzle.ts # Database schema
└── api/
└── [...].ts # CRUD endpoints
Database Schema
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
}
Usage
In Schema Definitions
Reference assets in your collection schemas:
{
"imageId": {
"type": "string",
"refTarget": "assets",
"meta": {
"component": "CroutonAssetsPicker",
"label": "Featured Image",
"area": "main"
}
}
}
This automatically generates:
- Asset picker in the form
- Thumbnail preview in list views
- Proper validation
refTarget points to a collection named assets, images, files, or media, the generator will automatically use CroutonAssetsPicker even without specifying the component in meta!Asset Picker Component
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:
- Grid view with thumbnails
- Real-time search
- Upload new assets inline
- Auto-refresh after uploads
Asset Uploader Component
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>
Programmatic Upload
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>
Architecture
How It Works
- Base Package (
nuxt-crouton)- Provides core upload infrastructure
/api/upload-imageendpoint/images/[pathname]serving route- Basic upload components
- Assets Package (
nuxt-crouton-assets)- Provides reusable tools and components
CroutonAssetsPickercomponentCroutonAssetsUploadercomponentuseAssetUpload()composable- Reference schema for generation
- Your Project (Generated Collection)
layers/core/collections/assets/- CRUD forms and API endpoints
- Database tables and validation
- Team-scoped asset management
Upload Flow
1. User selects file
↓
2. File uploaded to NuxtHub blob storage
→ POST /api/upload-image
→ Returns pathname
↓
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
Serving Assets
Browser requests: /images/[pathname]
↓
GET /images/[pathname] route
↓
Fetches from blob storage
↓
Serves file to browser
Common Patterns
Product with Image
// 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.
Multiple Images Gallery
// 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.
Avatar Upload
For simple avatar uploads without the asset library:
<template>
<CroutonAvatarUpload
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 pathname = await $fetch('/api/upload-image', {
method: 'POST',
body: formData
})
avatarUrl.value = `/images/${pathname}`
// Update user profile
await $fetch(`/api/users/${user.id}`, {
method: 'PATCH',
body: { avatar: pathname }
})
}
</script>
Batch Upload
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>
Displaying Assets
In List Views
The generator automatically creates CardMini components for asset references:
<template>
<CroutonList
:rows="products"
:columns="columns"
collection="shopProducts"
>
<!-- Auto-generated for asset references -->
<template #imageId-cell="{ row }">
<CardMini
v-if="row.original.imageId"
:id="row.original.imageId"
collection="assets"
/>
</template>
</CroutonList>
</template>
Custom Display
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>
Search and Browse
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:
- Filename
- Alt text
Best Practices
Alt Text
Always provide descriptive alt text:
await uploadAsset(file, {
alt: 'Red Nike sneakers on white background, side view'
})
Good alt text:
- Describes the image content
- Helps with SEO
- Improves accessibility
- Makes search more effective
File Naming
Use descriptive filenames:
await uploadAsset(file, {
filename: 'red-nike-air-max-90-side.jpg',
alt: 'Red Nike Air Max 90, side view'
})
Organize by Team
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
Reuse 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:
- Saves storage space
- Consistent images across products
- Update once, reflects everywhere
Custom Asset Collections
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" />
Troubleshooting
Assets Not Showing
- Check NuxtHub blob storage is enabled:
// nuxt.config.ts
export default defineNuxtConfig({
hub: {
blob: true
}
})
- Verify assets collection is generated:
ls layers/core/collections/assets
- Check team ID in route:
// Asset picker needs team in route params
GET /teams/:team/...
Upload Fails
- Check file size limits
- Verify blob storage permissions
- Check network connectivity
- Review server logs for errors
Images Not Displaying
- Verify pathname is correct:
console.log('Pathname:', asset.pathname)
console.log('Full URL:', `/images/${asset.pathname}`)
- Check blob storage:
# In NuxtHub dashboard, verify file exists
- Test serving route:
curl http://localhost:3000/images/[pathname]
Migration from Direct URLs
If you're currently storing URLs directly, you can migrate to the asset system:
1. Update Schema
{
- "imageUrl": { "type": "string" }
+ "imageId": {
+ "type": "string",
+ "refTarget": "assets"
+ // Component auto-detected as CroutonAssetsPicker
+ }
}
2. Migrate Data
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))
}
3. Regenerate Components
crouton-generate products shopProducts --overwrite
Related Resources
- Components Reference - Asset components API
- Composables Reference - useAssetUpload API
- NuxtHub Documentation - Blob storage
- Drizzle ORM - Database operations