Guides

Asset Management

Complete guide to managing media files with nuxt-crouton-assets

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 CroutonImageUpload component
  • 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-crouton installed
  • @nuxthub/core with 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
Auto-Detection: If your 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

  1. Base Package (nuxt-crouton)
    • Provides core upload infrastructure
    • /api/upload-image endpoint
    • /images/[pathname] serving route
    • Basic upload components
  2. Assets Package (nuxt-crouton-assets)
    • Provides reusable tools and components
    • CroutonAssetsPicker component
    • CroutonAssetsUploader component
    • useAssetUpload() composable
    • Reference schema for generation
  3. 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.

// 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

  1. Check NuxtHub blob storage is enabled:
// nuxt.config.ts
export default defineNuxtConfig({
  hub: {
    blob: true
  }
})
  1. Verify assets collection is generated:
ls layers/core/collections/assets
  1. Check team ID in route:
// Asset picker needs team in route params
GET /teams/:team/...

Upload Fails

  1. Check file size limits
  2. Verify blob storage permissions
  3. Check network connectivity
  4. Review server logs for errors

Images Not Displaying

  1. Verify pathname is correct:
console.log('Pathname:', asset.pathname)
console.log('Full URL:', `/images/${asset.pathname}`)
  1. Check blob storage:
# In NuxtHub dashboard, verify file exists
  1. 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