Assets Package (BETA)
@friendlyinternet/nuxt-crouton-assets package is in active development. APIs may change before the stable release. Use with caution in production.The assets package extends Nuxt Crouton with a centralized media library system, providing full-featured asset management with team-based ownership, rich metadata tracking, and NuxtHub blob storage integration.
Overview
Package Information
- Package:
@friendlyinternet/nuxt-crouton-assets - Version: 0.3.0 (BETA)
- Type: Nuxt Layer / Addon Package
- Repository: nuxt-crouton monorepo
What's Included
Components (2):
CroutonAssetsPicker- Browse and select assets from your media libraryCroutonAssetsUploader- Upload files with metadata form
Composable (1):
useAssetUpload()- Programmatic asset upload handling
Integration:
- NuxtHub blob storage configuration
- Reference schema for collection generation
- Auto-detection for asset reference fields
Key Features
- 📸 Centralized Library - Single source of truth for all media uploads
- 🎯 Visual Picker - Browse assets with thumbnail grid and search
- 📊 Rich Metadata - Track filename, size, MIME type, alt text, timestamps
- 👥 Team-Scoped - Assets automatically scoped to teams/organizations
- 🔍 Search & Filter - Find assets by filename or alt text
- ♿ Accessibility - Alt text support with i18n integration
- 🔄 Reusable - Reference same asset across multiple collections
- ⚡ Edge Storage - Powered by NuxtHub blob storage on Cloudflare
Installation
Prerequisites
Before installing, ensure you have:
- Nuxt 4.0+
@friendlyinternet/nuxt-croutoninstalled@nuxthub/core^0.7.0 or higher@vueuse/core^11.0.0 or higher
Install 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 assets layer
],
hub: {
blob: true // REQUIRED: Enable NuxtHub blob storage
}
})
hub.blob: true) is required for the assets package to function.Generate Assets Collection
The package provides tools (components and composables), but you need to generate the actual collection in your project:
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 with asset metadata
├── List.vue # Asset library list view
├── CardMini.vue # Asset preview card
├── index.ts # Exports
├── schema.ts # Zod validation
├── drizzle.ts # Database schema
└── api/
└── [...].ts # CRUD endpoints
Architecture
How It Works
The assets package follows a toolkit pattern - it provides reusable components and composables that work with your generated collection:
- Base Package (
@friendlyinternet/nuxt-crouton)- Core upload infrastructure
POST /api/upload-image- Upload to blobGET /images/[pathname]- Serve from blob- Basic upload components
- Assets Package (
@friendlyinternet/nuxt-crouton-assets) - This Package- Reusable components and composables
CroutonAssetsPicker- Visual selectorCroutonAssetsUploader- Upload + metadata formuseAssetUpload()- Programmatic APIassets-schema.json- Reference schema
- 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 in CroutonAssetsUploader
↓
2. File uploaded to NuxtHub blob storage
→ POST /api/upload-image
→ Returns pathname (e.g., "uploads/abc123.jpg")
↓
3. Asset record created in database
→ POST /api/teams/[teamId]/assets
→ Stores: filename, pathname, contentType, size, alt, etc.
↓
4. Asset now available in media library
→ GET /api/teams/[teamId]/assets
→ CroutonAssetsPicker displays it
Database Schema
The generated assets collection includes these fields:
{
id: string // Unique identifier (primaryKey)
teamId: string // Team/organization ownership (required)
userId: string // User who uploaded (required)
filename: string // Original filename (required)
pathname: string // Blob storage path (required)
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 (auto)
updatedAt: Date // Record updated (auto)
updatedBy: string // Last modifier
}
Components
CroutonAssetsPicker
Browse and select assets from your media library.
Props
interface Props {
collection?: string // Collection name (default: 'assets')
}
// v-model
modelValue: string // Selected asset ID
Features
- Grid View: Thumbnail grid with 4-column layout
- Search: Real-time filtering by filename or alt text
- Upload: Inline upload button opens modal with uploader
- Selection: Visual feedback with border and checkmark
- Auto-refresh: Refreshes list after upload
- Loading States: Skeleton loading for pending data
Usage
In Forms (Schema Definition)
The easiest way is to reference assets in your collection schema:
{
"imageId": {
"type": "string",
"refTarget": "assets",
"meta": {
"component": "CroutonAssetsPicker",
"label": "Featured Image",
"area": "main"
}
}
}
refTarget points to assets, images, files, or media, the generator automatically uses CroutonAssetsPicker - no need to specify the component!Direct Usage
<template>
<UFormField label="Product Image" name="imageId">
<CroutonAssetsPicker v-model="state.imageId" />
</UFormField>
</template>
<script setup lang="ts">
const state = ref({
imageId: ''
})
// Access selected asset ID
watch(() => state.imageId, (newId) => {
console.log('Selected asset:', newId)
})
</script>
Custom Collection
<template>
<CroutonAssetsPicker
v-model="selectedId"
collection="productImages"
/>
</template>
<script setup lang="ts">
const selectedId = ref('')
</script>
Events
// Emitted when asset is selected
@update:modelValue: (assetId: string) => void
Component Source
Located at: packages/nuxt-crouton-assets/app/components/Picker.vue
Key Implementation Details:
- Uses
useFetch()to load assets from generated API - Filters assets client-side with computed property
- Modal integration with
CroutonAssetsUploader - Team ID from route params (
useRoute().params.team)
CroutonAssetsUploader
Upload files with metadata form (alt text, filename display).
Props
interface Props {
collection?: string // Collection name (default: 'assets')
}
Events
@uploaded: (assetId: string) => void // Emitted after successful upload
Features
- File Selection: Uses
CroutonImageUploadfor file picker - Preview: Image preview before upload
- Metadata Form: Alt text input field
- File Info: Displays filename, size, MIME type
- Upload State: Loading indicator during upload
- Two-Step Process:
- Upload file to blob storage
- Create asset record in database
Usage
In Modal
<template>
<div>
<UButton @click="showUploader = true">
Upload New Asset
</UButton>
<UModal v-model="showUploader">
<template #content="{ close }">
<div class="p-6">
<h3 class="text-lg font-semibold mb-4">Upload New Asset</h3>
<CroutonAssetsUploader @uploaded="handleUploaded(close)" />
</div>
</template>
</UModal>
</div>
</template>
<script setup lang="ts">
const showUploader = ref(false)
const handleUploaded = async (close: () => void, assetId: string) => {
console.log('Uploaded asset ID:', assetId)
// Optionally refresh your asset list
close()
showUploader.value = false
}
</script>
Standalone
<template>
<div class="max-w-md mx-auto">
<CroutonAssetsUploader @uploaded="onAssetUploaded" />
</div>
</template>
<script setup lang="ts">
const onAssetUploaded = (assetId: string) => {
console.log('New asset:', assetId)
// Navigate to asset or show success message
navigateTo(`/assets/${assetId}`)
}
</script>
Component Source
Located at: packages/nuxt-crouton-assets/app/components/Uploader.vue
Upload Process:
- User selects file via
CroutonImageUpload - File preview and metadata form appears
- User enters alt text (optional)
- Click "Upload Asset"
- File uploads to blob storage (
POST /api/upload-image) - Asset record created in database (
POST /api/teams/[id]/assets) @uploadedevent emitted with asset ID- Form resets
Composable
useAssetUpload()
Programmatic asset upload handling for custom workflows.
API
const {
uploadAsset,
uploadAssets,
uploading,
error
} = useAssetUpload()
Returns
{
// Upload single asset
uploadAsset: (
file: File,
metadata?: AssetMetadata,
collection?: string
) => Promise<UploadAssetResult>
// Upload multiple assets in parallel
uploadAssets: (
files: File[],
metadata?: AssetMetadata,
collection?: string
) => Promise<UploadAssetResult[]>
// Reactive state
uploading: Readonly<Ref<boolean>>
error: Readonly<Ref<Error | null>>
}
Types
interface AssetMetadata {
alt?: string
filename?: string
}
interface UploadAssetResult {
id: string
pathname: string
filename: string
contentType: string
size: number
alt?: string
}
Usage Examples
Simple Upload
<script setup lang="ts">
const { uploadAsset, uploading, error } = useAssetUpload()
const handleFileInput = async (event: Event) => {
const file = (event.target as HTMLInputElement).files?.[0]
if (!file) return
try {
const asset = await uploadAsset(file, {
alt: 'User uploaded image',
filename: file.name
})
console.log('Upload successful:', asset.id)
} catch (err) {
console.error('Upload failed:', error.value)
}
}
</script>
<template>
<div>
<input type="file" @change="handleFileInput" :disabled="uploading" />
<p v-if="uploading">Uploading...</p>
<p v-if="error" class="text-red-500">{{ error.message }}</p>
</div>
</template>
Drag-and-Drop Upload
<script setup lang="ts">
const { uploadAsset, uploading } = useAssetUpload()
const handleDrop = async (event: DragEvent) => {
event.preventDefault()
const file = event.dataTransfer?.files[0]
if (!file) return
const asset = await uploadAsset(file, {
alt: 'Drag-and-drop upload'
})
console.log('Dropped file uploaded:', asset.id)
}
const handleDragOver = (event: DragEvent) => {
event.preventDefault()
}
</script>
<template>
<div
@drop="handleDrop"
@dragover="handleDragOver"
class="border-2 border-dashed rounded-lg p-12 text-center"
:class="uploading ? 'opacity-50' : 'hover:border-primary-500'"
>
<p v-if="!uploading">Drop files here to upload</p>
<p v-else>Uploading...</p>
</div>
</template>
Batch Upload
<script setup lang="ts">
const { uploadAssets, uploading } = useAssetUpload()
const handleMultipleFiles = async (event: Event) => {
const files = Array.from((event.target as HTMLInputElement).files || [])
if (files.length === 0) return
try {
const assets = await uploadAssets(files, {
alt: 'Batch uploaded images'
})
console.log(`Uploaded ${assets.length} assets`)
assets.forEach(asset => {
console.log(`- ${asset.filename}: ${asset.id}`)
})
} catch (err) {
console.error('Batch upload failed')
}
}
</script>
<template>
<div>
<input
type="file"
multiple
@change="handleMultipleFiles"
:disabled="uploading"
/>
<p v-if="uploading">Uploading multiple files...</p>
</div>
</template>
Custom Collection
<script setup lang="ts">
const { uploadAsset } = useAssetUpload()
const uploadProductImage = async (file: File) => {
// Upload to custom collection
const asset = await uploadAsset(
file,
{ alt: 'Product image' },
'productImages' // Custom collection
)
return asset.id
}
</script>
Error Handling
The composable catches errors and stores them in the error ref:
<script setup lang="ts">
const { uploadAsset, error } = useAssetUpload()
const upload = async (file: File) => {
try {
await uploadAsset(file)
} catch (err) {
// error.value is also set
console.error('Upload failed:', error.value?.message)
// Show toast notification
useToast().add({
title: 'Upload Failed',
description: error.value?.message || 'Unknown error',
color: 'red'
})
}
}
</script>
NuxtHub Blob Storage
The assets package relies on NuxtHub's blob storage for file management.
Configuration
Required in nuxt.config.ts:
export default defineNuxtConfig({
hub: {
blob: true // Enable blob storage
}
})
How Blob Storage Works
Upload Endpoint (provided by base package):
// POST /api/upload-image
// Receives: FormData with 'image' field
// Returns: string (pathname)
// Example: "uploads/team-123/abc123.jpg"
Serving Route (provided by base package):
// GET /images/[pathname]
// Fetches from blob storage
// Serves file with correct content-type
File Organization
Files are stored with unique pathnames:
uploads/
├── team-123/
│ ├── abc123.jpg
│ ├── def456.png
│ └── ghi789.webp
└── team-456/
├── jkl012.jpg
└── mno345.png
Benefits of Edge Storage
- Global CDN: Fast delivery worldwide
- Automatic Scaling: No storage limits
- Cost Effective: Pay per usage
- Cloudflare Integration: Seamless with NuxtHub
- No Configuration: Works out of the box
Common Patterns
Product with Image
Schema Definition:
{
"name": {
"type": "string",
"meta": { "required": true, "area": "main" }
},
"description": {
"type": "string",
"meta": { "component": "Textarea", "area": "main" }
},
"imageId": {
"type": "string",
"refTarget": "assets",
"meta": {
"label": "Product Image",
"area": "sidebar"
}
},
"price": {
"type": "number",
"meta": { "required": true, "area": "sidebar" }
}
}
Generated Form: Automatically includes asset picker thanks to auto-detection!
Display Product with Image:
<script setup lang="ts">
const route = useRoute()
const teamId = route.params.team as string
const productId = route.params.id as string
// Fetch product
const { data: product } = await useFetch(
`/api/teams/${teamId}/products/${productId}`
)
// Fetch referenced asset
const { data: asset } = await useFetch(
() => product.value?.imageId
? `/api/teams/${teamId}/assets/${product.value.imageId}`
: null,
{ watch: [() => product.value?.imageId] }
)
const imageUrl = computed(() =>
asset.value?.pathname ? `/images/${asset.value.pathname}` : '/placeholder.png'
)
</script>
<template>
<div v-if="product">
<img
:src="imageUrl"
:alt="asset?.alt || product.name"
class="w-full h-64 object-cover rounded-lg"
/>
<h1>{{ product.name }}</h1>
<p>{{ product.description }}</p>
<p class="text-2xl font-bold">${{ product.price }}</p>
</div>
</template>
Avatar Upload (Simple)
For user avatars, you might prefer the simple approach without the asset library:
<template>
<CroutonAvatarUpload
v-model="avatarUrl"
@file-selected="handleUpload"
/>
</template>
<script setup lang="ts">
const user = useCurrentUser()
const avatarUrl = ref(user.value?.avatar || '/default-avatar.png')
const handleUpload = 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.value.id}`, {
method: 'PATCH',
body: { avatar: pathname }
})
}
</script>
Multiple Images Gallery
Schema with Multiple Assets:
{
"title": { "type": "string", "meta": { "required": true } },
"featuredImageId": {
"type": "string",
"refTarget": "assets",
"meta": { "label": "Featured Image" }
},
"galleryImageIds": {
"type": "json",
"meta": {
"label": "Gallery Images",
"component": "MultiAssetPicker"
}
}
}
Custom Multi-Picker Component:
<!-- components/MultiAssetPicker.vue -->
<template>
<div class="space-y-4">
<div class="flex flex-wrap gap-2">
<div
v-for="(assetId, index) in selectedIds"
:key="assetId"
class="relative group"
>
<img
:src="`/images/${getAssetPathname(assetId)}`"
class="w-24 h-24 object-cover rounded"
/>
<button
@click="removeAsset(index)"
class="absolute -top-2 -right-2 bg-red-500 rounded-full p-1"
>
<UIcon name="i-heroicons-x-mark" class="w-4 h-4 text-white" />
</button>
</div>
</div>
<UButton @click="showPicker = true">
Add Images
</UButton>
<UModal v-model="showPicker">
<template #content="{ close }">
<div class="p-6">
<h3 class="text-lg font-semibold mb-4">Select Images</h3>
<CroutonAssetsPicker v-model="tempSelection" />
<div class="flex justify-end gap-2 mt-4">
<UButton variant="ghost" @click="close">Cancel</UButton>
<UButton @click="addAsset(close)">Add</UButton>
</div>
</div>
</template>
</UModal>
</div>
</template>
<script setup lang="ts">
const selectedIds = defineModel<string[]>({ default: () => [] })
const showPicker = ref(false)
const tempSelection = ref('')
const addAsset = (close: () => void) => {
if (tempSelection.value && !selectedIds.value.includes(tempSelection.value)) {
selectedIds.value.push(tempSelection.value)
}
tempSelection.value = ''
close()
}
const removeAsset = (index: number) => {
selectedIds.value.splice(index, 1)
}
const getAssetPathname = (assetId: string) => {
// Fetch pathname from asset - implement caching
return assetId
}
</script>
Reusable Assets Across Collections
// Same asset used in multiple places
{
product1: { imageId: 'asset-abc123' },
product2: { imageId: 'asset-abc123' },
blogPost: { featuredImageId: 'asset-abc123' }
}
Benefits:
- Saves storage space (file stored once)
- Consistent images across application
- Update alt text once, reflects everywhere
- Centralized asset management
Best Practices
Alt Text Guidelines
Always provide descriptive alt text for accessibility and SEO:
Good Alt Text:
await uploadAsset(file, {
alt: 'Red Nike Air Max 90 sneakers on white background, side view'
})
Bad Alt Text:
await uploadAsset(file, {
alt: 'image' // Too generic
})
await uploadAsset(file, {
alt: '' // Missing
})
Benefits of Good Alt Text:
- Improves accessibility for screen readers
- Boosts SEO rankings
- Makes search more effective
- Helps content discovery
File Naming Conventions
Use descriptive, consistent filenames:
// Good
await uploadAsset(file, {
filename: 'red-nike-air-max-90-side-view.jpg',
alt: 'Red Nike Air Max 90, side view'
})
// Avoid
await uploadAsset(file, {
filename: 'IMG_1234.jpg', // Not descriptive
alt: ''
})
Team-Based Organization
Assets are automatically scoped to teams via teamId:
// Team A's assets
GET /api/teams/team-123/assets
// Returns only team-123's assets
// Team B's assets
GET /api/teams/team-456/assets
// Returns only team-456's assets
No manual filtering needed - the generated API handles team scoping.
Search Optimization
Make assets easy to find:
await uploadAsset(file, {
filename: 'summer-collection-beach-sunset.jpg',
alt: 'Summer collection photo shoot at beach during golden hour sunset'
})
Search will match:
- "summer" → Found via filename or alt
- "beach" → Found via alt text
- "sunset" → Found via both
- "collection" → Found via filename
Troubleshooting
Assets Not Displaying in Picker
Check NuxtHub blob storage is enabled:
// nuxt.config.ts
export default defineNuxtConfig({
hub: {
blob: true // Must be true
}
})
Verify assets collection exists:
ls layers/core/collections/assets
# Should show: Form.vue, List.vue, drizzle.ts, etc.
Check team ID in route:
The picker needs team in route params:
// Route must include team
/teams/:team/products
/teams/:team/blog-posts
Upload Fails
Check file size limits:
NuxtHub blob storage has default limits. For large files, configure limits.
Verify blob storage permissions:
Ensure your NuxtHub project has blob storage enabled in the dashboard.
Review server logs:
# Check for errors
pnpm dev
# Look for upload errors in console
Images Not Serving
Verify pathname is correct:
console.log('Pathname:', asset.pathname)
console.log('Full URL:', `/images/${asset.pathname}`)
Test serving route directly:
curl http://localhost:3000/images/uploads/team-123/abc123.jpg
Check blob storage:
In NuxtHub dashboard, verify the file exists in blob storage.
TypeScript Errors
Run typecheck after generation:
npx nuxt typecheck
Common issues:
- Missing
teamIdin route params - Incorrect asset ID type (should be
string) - Missing await on async operations
Migration Guide
From Direct URL Storage
If you're currently storing image URLs directly in your database:
1. Update Schema
{
"name": { "type": "string" },
- "imageUrl": { "type": "string" }
+ "imageId": {
+ "type": "string",
+ "refTarget": "assets"
+ }
}
2. Generate Assets Collection
crouton-generate core assets \
--fields-file=node_modules/@friendlyinternet/nuxt-crouton-assets/assets-schema.json
3. Migrate Existing Data
Create migration script:
// scripts/migrate-to-assets.ts
import { db } from '~/server/db'
import { products, assets } from '~/server/db/schema'
const allProducts = await db.select().from(products)
for (const product of allProducts) {
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 || 'system',
filename: pathname.split('/').pop() || 'unknown.jpg',
pathname,
contentType: 'image/jpeg', // Detect from file extension
size: 0, // Could fetch from blob
uploadedAt: product.createdAt,
alt: product.name // Use product name as fallback alt
}).returning()
// Update product reference
await db
.update(products)
.set({ imageId: asset.id })
.where(eq(products.id, product.id))
}
console.log(`Migrated ${allProducts.length} products`)
4. Regenerate Collection
crouton-generate products shopProducts --overwrite
API Reference
Component Props
CroutonAssetsPicker
{
collection?: string // Default: 'assets'
modelValue?: string // v-model (asset ID)
}
CroutonAssetsUploader
{
collection?: string // Default: 'assets'
}
// Events:
// @uploaded(assetId: string)
Composable Types
// useAssetUpload() return type
interface UseAssetUploadReturn {
uploadAsset: (
file: File,
metadata?: AssetMetadata,
collection?: string
) => Promise<UploadAssetResult>
uploadAssets: (
files: File[],
metadata?: AssetMetadata,
collection?: string
) => Promise<UploadAssetResult[]>
uploading: Readonly<Ref<boolean>>
error: Readonly<Ref<Error | null>>
}
interface AssetMetadata {
alt?: string
filename?: string
}
interface UploadAssetResult {
id: string
pathname: string
filename: string
contentType: string
size: number
alt?: string
}
Known Limitations (BETA)
- Single File Upload Only - The uploader currently handles one file at a time. Batch uploads require custom implementation with
useAssetUpload(). - No Image Editing - No built-in cropping, resizing, or filters. Upload images pre-processed or add custom editing UI.
- No Automatic Thumbnails - Large images are served at full resolution. Consider generating thumbnails separately.
- Basic Search - Client-side filtering by filename and alt text only. For advanced search, implement server-side filtering.
- No Asset Deletion UI - Use the generated List.vue or implement custom delete functionality.
- Team Scoping Required - Assets must be scoped to teams. For global assets, create a "global" team or adjust the schema.
Roadmap (Future Versions)
Planned Features:
- Multi-file upload support in uploader component
- Image transformation and optimization
- Automatic thumbnail generation
- Folder/tag organization
- Advanced search with filters
- Asset usage tracking (where asset is referenced)
- Bulk operations (delete, move, tag)
- Image editing integration
- Video/document support
- Storage analytics
API Stability:
- Current API will remain backward compatible
- Major changes will follow semantic versioning
- Migration guides provided for breaking changes
Related Resources
- Asset Management Guide - Complete usage guide
- NuxtHub Blob Storage - Storage documentation
- Component Generator - Collection generation
- Base Package - Core upload infrastructure
Feedback
This is a BETA package. Your feedback is valuable!
- Issues: GitHub Issues
- Discussions: GitHub Discussions
- Feature Requests: Tag with
nuxt-crouton-assets
Version History
v0.3.0 (Current - BETA)
- Initial beta release
- CroutonAssetsPicker component
- CroutonAssetsUploader component
- useAssetUpload() composable
- NuxtHub blob storage integration
- Auto-detection for asset references
- Reference schema for generation