Pushduck
Pushduck// S3 uploads for any framework

Client-Side Metadata

Pass contextual data from your UI to the server for dynamic file organization and categorization

Client-Side Metadata

Client-side metadata allows you to pass contextual information from your UI directly to the server during file uploads. This enables dynamic file organization, categorization, and processing based on user selections and application state.

New Feature: As of v0.1.23, you can now pass metadata from the client when calling uploadFiles(). This metadata flows through to your middleware, lifecycle hooks, and path generation functions.

Why Use Client Metadata?

Client metadata bridges the gap between UI context and server-side processing:

  • 🎯 UI State - Pass album selections, categories, or form data
  • 🏢 Multi-tenant Context - Send workspace, project, or organization IDs
  • 🏷️ User Preferences - Include tags, visibility settings, or custom fields
  • 📊 Dynamic Organization - Organize files based on client context
  • 🎨 Flexible Workflows - Adapt to different use cases without API changes

Basic Usage

Client: Pass metadata with uploadFiles

import { upload } from '@/lib/upload-client';

export function ImageUploader() {
  const { uploadFiles } = upload.imageUpload();
  
  const handleUpload = (files: File[]) => {
    // Pass metadata as second parameter
    uploadFiles(files, {
      albumId: 'vacation-2025',
      tags: ['beach', 'sunset'],
      visibility: 'private'
    });
  };
  
  return <input type="file" multiple onChange={(e) => handleUpload(Array.from(e.target.files || []))} />;
}

Server: Receive in middleware

// app/api/upload/route.ts
const s3Router = s3.createRouter({
  imageUpload: s3.image()
    .middleware(async ({ req, metadata }) => {
      const user = await authenticateUser(req);
      
      return {
        ...metadata,      // Client data: { albumId, tags, visibility }
        userId: user.id,  // Server data from auth
      };
    })
});

Use in hooks and path generation

.paths({
  generateKey: (ctx) => {
    // Metadata available in path generation
    return `users/${ctx.metadata.userId}/albums/${ctx.metadata.albumId}/${ctx.file.name}`;
  }
})
.onUploadComplete(async ({ metadata, url }) => {
  // Metadata available in lifecycle hooks
  await db.images.create({
    url,
    albumId: metadata.albumId,
    tags: metadata.tags,
    userId: metadata.userId
  });
})

Real-World Examples

Multi-Tenant SaaS Application

// Client component
export function WorkspaceFileUpload({ workspace, project }: Props) {
  const { uploadFiles } = upload.documentUpload();
  
  const handleUpload = (files: File[]) => {
    uploadFiles(files, {
      workspaceId: workspace.id,
      projectId: project.id,
      teamId: workspace.team.id,
      folder: selectedFolder.path,
      permissions: {
        canEdit: currentUser.role === 'admin',
        canDelete: currentUser.role === 'admin',
        canShare: true
      }
    });
  };
  
  return <FileUploadZone onFilesSelected={handleUpload} />;
}
// Server middleware - validates tenant isolation
.middleware(async ({ req, metadata }) => {
  const user = await authenticateUser(req);
  
  // Verify user belongs to workspace
  const membership = await db.workspaceMemberships.findFirst({
    where: {
      workspaceId: metadata?.workspaceId,
      userId: user.id
    }
  });
  
  if (!membership) {
    throw new Error('Access denied to workspace');
  }
  
  // Verify user can access project
  const project = await db.projects.findFirst({
    where: {
      id: metadata?.projectId,
      workspaceId: metadata?.workspaceId
    }
  });
  
  if (!project) {
    throw new Error('Project not found');
  }
  
  return {
    // Validated client metadata
    workspaceId: metadata.workspaceId,
    projectId: metadata.projectId,
    folder: metadata.folder || '/',
    
    // Server metadata
    userId: user.id,
    userRole: membership.role,
    uploadedAt: new Date().toISOString()
  };
})
.paths({
  generateKey: (ctx) => {
    const { metadata, file } = ctx;
    return `workspaces/${metadata.workspaceId}/projects/${metadata.projectId}${metadata.folder}/${file.name}`;
  }
})

E-Commerce Product Images

// Client: Product image upload with variants
export function ProductImageManager({ product }: { product: Product }) {
  const [imageType, setImageType] = useState<'main' | 'gallery' | 'thumbnail'>('gallery');
  const [selectedVariant, setSelectedVariant] = useState<string | null>(null);
  
  const { uploadFiles, files } = upload.productImages();
  
  const handleUpload = (files: File[]) => {
    uploadFiles(files, {
      productId: product.id,
      variantId: selectedVariant,
      imageType: imageType,
      sortOrder: product.images.length + 1,
      altText: `${product.name} - ${imageType} image`
    });
  };
  
  return (
    <div>
      <select value={imageType} onChange={(e) => setImageType(e.target.value as any)}>
        <option value="main">Main Product Image</option>
        <option value="gallery">Gallery Image</option>
        <option value="thumbnail">Thumbnail</option>
      </select>
      
      {product.variants.length > 0 && (
        <select value={selectedVariant || ''} onChange={(e) => setSelectedVariant(e.target.value || null)}>
          <option value="">All Variants</option>
          {product.variants.map(v => (
            <option key={v.id} value={v.id}>{v.name}</option>
          ))}
        </select>
      )}
      
      <input type="file" multiple onChange={(e) => e.target.files && handleUpload(Array.from(e.target.files))} />
    </div>
  );
}
// Server: Organize by product and variant
.middleware(async ({ req, metadata }) => {
  const user = await authenticateUser(req);
  
  // Verify user owns product
  const product = await db.products.findFirst({
    where: {
      id: metadata?.productId,
      ownerId: user.id
    }
  });
  
  if (!product) {
    throw new Error('Product not found or access denied');
  }
  
  return {
    productId: metadata.productId,
    variantId: metadata.variantId,
    imageType: metadata.imageType,
    sortOrder: metadata.sortOrder,
    userId: user.id,
    merchantId: product.merchantId
  };
})
.paths({
  generateKey: (ctx) => {
    const { metadata, file } = ctx;
    const variantPath = metadata.variantId ? `/variants/${metadata.variantId}` : '';
    return `products/${metadata.productId}${variantPath}/${metadata.imageType}/${metadata.sortOrder}-${file.name}`;
  }
})
.onUploadComplete(async ({ metadata, url }) => {
  await db.productImages.create({
    productId: metadata.productId,
    variantId: metadata.variantId,
    type: metadata.imageType,
    url: url,
    sortOrder: metadata.sortOrder,
    altText: metadata.altText
  });
})

Content Management System

// Client: Content upload with categorization
export function CMSMediaUpload() {
  const [contentType, setContentType] = useState('article');
  const [category, setCategory] = useState('technology');
  const [tags, setTags] = useState<string[]>([]);
  const [publishDate, setPublishDate] = useState<string>('');
  
  const { uploadFiles } = upload.mediaUpload();
  
  const handleUpload = (files: File[]) => {
    uploadFiles(files, {
      contentType: contentType,
      category: category,
      tags: tags,
      publishDate: publishDate || new Date().toISOString(),
      featured: false,
      author: currentUser.username
    });
  };
  
  return (
    <form>
      <select value={contentType} onChange={(e) => setContentType(e.target.value)}>
        <option value="article">Article</option>
        <option value="video">Video</option>
        <option value="podcast">Podcast</option>
      </select>
      
      <select value={category} onChange={(e) => setCategory(e.target.value)}>
        <option value="technology">Technology</option>
        <option value="business">Business</option>
        <option value="lifestyle">Lifestyle</option>
      </select>
      
      <input
        type="date"
        value={publishDate}
        onChange={(e) => setPublishDate(e.target.value)}
      />
      
      <input type="file" multiple onChange={(e) => e.target.files && handleUpload(Array.from(e.target.files))} />
    </form>
  );
}
// Server: CMS organization
.middleware(async ({ req, metadata }) => {
  const user = await authenticateUser(req);
  
  // Verify user has content creation permissions
  if (!user.permissions.includes('create:content')) {
    throw new Error('Insufficient permissions');
  }
  
  return {
    contentType: metadata?.contentType || 'article',
    category: metadata?.category,
    tags: metadata?.tags || [],
    publishDate: metadata?.publishDate,
    authorId: user.id,
    authorName: user.name,
    status: 'draft'
  };
})
.paths({
  generateKey: (ctx) => {
    const { metadata, file } = ctx;
    const date = new Date(metadata.publishDate);
    const year = date.getFullYear();
    const month = String(date.getMonth() + 1).padStart(2, '0');
    
    return `content/${metadata.contentType}/${year}/${month}/${metadata.category}/${file.name}`;
  }
})

Security Best Practices

⚠️ CRITICAL: Client metadata is UNTRUSTED user input.

Never trust identity claims, permissions, or security-related data from the client. Always validate and extract identity from authenticated sessions on the server.

❌ DON'T: Trust Client Identity

// ❌ BAD: Trusting client-provided userId
uploadFiles(files, {
  userId: currentUser.id,    // Client can fake this
  isAdmin: true,             // Client can lie about this
  role: 'admin'              // Never trust this from client
});

.middleware(async ({ metadata }) => {
  return {
    userId: metadata.userId,  // ❌ DANGEROUS!
    role: metadata.role       // ❌ SECURITY RISK!
  };
})

✅ DO: Validate and Override

// ✅ GOOD: Server determines identity
uploadFiles(files, {
  albumId: selectedAlbum.id,     // ✅ OK - contextual data
  tags: selectedTags,            // ✅ OK - user input (validate)
  visibility: 'private'          // ✅ OK - user preference
});

.middleware(async ({ req, metadata }) => {
  const user = await authenticateUser(req);  // Server auth
  
  // Validate client data
  if (metadata?.albumId) {
    const album = await db.albums.findFirst({
      where: {
        id: metadata.albumId,
        userId: user.id  // Verify ownership
      }
    });
    if (!album) throw new Error('Invalid album');
  }
  
  return {
    // Client metadata (validated)
    albumId: metadata?.albumId,
    tags: sanitizeTags(metadata?.tags || []),
    
    // Server metadata (trusted)
    userId: user.id,          // ✅ From auth
    role: user.role,          // ✅ From auth
    uploadedAt: new Date().toISOString()
  };
})

Validation Strategies

Type Validation

.middleware(async ({ metadata }) => {
  // Validate data types
  if (metadata?.albumId && typeof metadata.albumId !== 'string') {
    throw new Error('Invalid albumId type');
  }
  
  if (metadata?.tags && !Array.isArray(metadata.tags)) {
    throw new Error('Tags must be an array');
  }
  
  if (metadata?.sortOrder && typeof metadata.sortOrder !== 'number') {
    throw new Error('Invalid sortOrder type');
  }
  
  return { ...metadata };
})

Value Validation

.middleware(async ({ metadata }) => {
  // Validate UUIDs
  const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
  if (metadata?.albumId && !uuidRegex.test(metadata.albumId)) {
    throw new Error('Invalid album ID format');
  }
  
  // Validate enums
  const validVisibilities = ['public', 'private', 'unlisted'];
  if (metadata?.visibility && !validVisibilities.includes(metadata.visibility)) {
    throw new Error('Invalid visibility setting');
  }
  
  // Sanitize strings
  const sanitizedTags = (metadata?.tags || []).map((tag: string) => 
    tag.trim().toLowerCase().replace(/[^a-z0-9-]/g, '')
  );
  
  return {
    ...metadata,
    tags: sanitizedTags
  };
})

Database Validation

.middleware(async ({ req, metadata }) => {
  const user = await authenticateUser(req);
  
  // Verify referenced entities exist and user has access
  if (metadata?.projectId) {
    const project = await db.projects.findFirst({
      where: {
        id: metadata.projectId,
        members: {
          some: { userId: user.id }
        }
      }
    });
    
    if (!project) {
      throw new Error('Project not found or access denied');
    }
  }
  
  if (metadata?.folderId) {
    const folder = await db.folders.findFirst({
      where: {
        id: metadata.folderId,
        projectId: metadata.projectId,
        deleted: false
      }
    });
    
    if (!folder) {
      throw new Error('Folder not found');
    }
  }
  
  return {
    ...metadata,
    userId: user.id
  };
})

TypeScript Support

Define metadata interfaces for better type safety:

// Define your metadata interface
interface UploadMetadata {
  albumId: string;
  tags: string[];
  visibility: 'public' | 'private' | 'unlisted';
  featured?: boolean;
}

// Client usage with type safety
const handleUpload = (files: File[]) => {
  const metadata: UploadMetadata = {
    albumId: selectedAlbum.id,
    tags: selectedTags,
    visibility: visibilityOption,
    featured: isFeatured
  };
  
  uploadFiles(files, metadata);
};

// Server middleware with typed metadata
.middleware(async ({ req, metadata }: { req: NextRequest; metadata?: UploadMetadata }) => {
  const user = await authenticateUser(req);
  
  return {
    ...metadata,
    userId: user.id
  };
})

Common Use Cases

Album/Gallery Organization

// Pass album selection from UI
uploadFiles(files, {
  albumId: selectedAlbum.id,
  albumName: selectedAlbum.name,
  tags: selectedTags,
  visibility: albumSettings.defaultVisibility
});

Document Management

// Pass folder structure and metadata
uploadFiles(files, {
  folderId: currentFolder.id,
  folderPath: currentFolder.fullPath,
  category: documentCategory,
  confidential: isConfidential,
  expiresAt: expirationDate
});

User Profile Assets

// Pass asset type and purpose
uploadFiles(files, {
  assetType: 'profile-picture',
  purpose: 'avatar',
  aspectRatio: '1:1',
  previousAssetId: currentAvatar?.id // For cleanup
});

Form Submissions

// Pass form context with uploads
uploadFiles(files, {
  formId: formSubmission.id,
  formType: 'contact',
  attachmentType: 'supporting-document',
  relatedTo: formData.ticketId
});

Advanced Patterns

Conditional Metadata

const handleUpload = (files: File[]) => {
  const metadata: any = {
    uploadSource: 'web-app',
    timestamp: Date.now()
  };
  
  // Conditionally add metadata
  if (selectedAlbum) {
    metadata.albumId = selectedAlbum.id;
  }
  
  if (tags.length > 0) {
    metadata.tags = tags;
  }
  
  if (isAdminUser) {
    metadata.priority = 'high';
    metadata.skipModeration = true;
  }
  
  uploadFiles(files, metadata);
};

Metadata from Form State

import { useForm } from 'react-hook-form';

export function FormWithUploads() {
  const { register, handleSubmit } = useForm();
  const { uploadFiles } = upload.attachments();
  
  const onSubmit = async (formData: any) => {
    // Upload files with form context
    await uploadFiles(selectedFiles, {
      formId: formData.id,
      category: formData.category,
      priority: formData.priority,
      department: formData.department,
      requestedBy: formData.requesterEmail
    });
    
    // Then submit form
    await submitForm(formData);
  };
  
  return <form onSubmit={handleSubmit(onSubmit)}>...</form>;
}

Dynamic Path Generation

// Client
uploadFiles(files, {
  organizationId: org.id,
  departmentId: dept.id,
  projectCode: project.code,
  fileClass: 'confidential'
});

// Server - organize by metadata
.paths({
  generateKey: (ctx) => {
    const { metadata, file } = ctx;
    const date = new Date();
    const year = date.getFullYear();
    const quarter = `Q${Math.ceil((date.getMonth() + 1) / 3)}`;
    
    return [
      'organizations',
      metadata.organizationId,
      'departments',
      metadata.departmentId,
      year.toString(),
      quarter,
      metadata.fileClass,
      metadata.projectCode,
      file.name
    ].join('/');
  }
})

Metadata Size Considerations

Size Limits: While there's no hard limit on metadata size, keep it reasonable (< 10KB). Large metadata objects increase request size and processing time.

✅ Good Metadata

// Compact and purposeful
{
  albumId: 'abc123',
  tags: ['vacation', 'beach'],
  visibility: 'private',
  featured: false
}

⚠️ Avoid Large Metadata

// Too large - send via separate API call
{
  albumId: 'abc123',
  fullImageData: base64EncodedImage,      // ❌ Don't embed files
  entireUserProfile: { ... },              // ❌ Too much data
  allPreviousUploads: [ ... ],            // ❌ Unnecessary
  complexNestedStructure: { ... }          // ⚠️ Keep it simple
}

Error Handling

Handle metadata-related errors gracefully:

const { uploadFiles } = upload.imageUpload({
  onError: (error) => {
    if (error.message.includes('album')) {
      toast.error('Selected album is invalid or you don\'t have access');
      resetAlbumSelection();
    } else if (error.message.includes('metadata')) {
      toast.error('Invalid upload settings. Please try again.');
    } else {
      toast.error('Upload failed: ' + error.message);
    }
  }
});

Testing with Metadata

import { render, fireEvent } from '@testing-library/react';

test('uploads files with correct metadata', async () => {
  const mockUploadFiles = vi.fn();
  
  const { getByLabelText, getByRole } = render(
    <ImageUploader uploadFiles={mockUploadFiles} />
  );
  
  // Select album
  const albumSelect = getByLabelText('Album');
  fireEvent.change(albumSelect, { target: { value: 'vacation-2025' } });
  
  // Add tags
  const tagsInput = getByLabelText('Tags');
  fireEvent.change(tagsInput, { target: { value: 'beach,sunset' } });
  
  // Upload files
  const fileInput = getByRole('file-input');
  const files = [new File(['content'], 'photo.jpg', { type: 'image/jpeg' })];
  fireEvent.change(fileInput, { target: { files } });
  
  // Verify metadata was passed correctly
  expect(mockUploadFiles).toHaveBeenCalledWith(
    expect.arrayContaining(files),
    expect.objectContaining({
      albumId: 'vacation-2025',
      tags: ['beach', 'sunset']
    })
  );
});

Best Practices

✅ DO

  • Pass UI state and user selections
  • Validate metadata in middleware
  • Use metadata for dynamic path generation
  • Keep metadata size reasonable (< 10KB)
  • Define TypeScript interfaces for metadata
  • Sanitize user input (tags, descriptions)
  • Verify access to referenced entities (albums, projects)

❌ DON'T

  • Trust client-provided identity (userId, role, permissions)
  • Send sensitive data (passwords, tokens, secrets)
  • Embed large objects (base64 files, entire datasets)
  • Skip validation in middleware
  • Use metadata for authentication
  • Trust metadata without verification

Migration Guide

If you're upgrading from a version without metadata support:

// Before: No metadata support
const { uploadFiles } = upload.imageUpload();
uploadFiles(files);

// After: Add metadata (backward compatible)
const { uploadFiles } = upload.imageUpload();

// Still works without metadata
uploadFiles(files);

// Or add metadata
uploadFiles(files, { albumId: album.id });

The feature is 100% backward compatible - existing code continues to work without changes.