Pushduck
Pushduck// S3 uploads for any framework

S3 Router

Build type-safe upload routes with schema validation, middleware, and lifecycle hooks. Complete S3 router configuration guide for image uploads, file validation, and custom paths.

S3 Router Configuration

The S3 router provides a type-safe way to define upload endpoints with schema validation, middleware, and lifecycle hooks.

Basic Router Setup

app/api/upload/route.ts
// app/api/upload/route.ts
import { s3 } from '@/lib/upload'

const s3Router = s3.createRouter({
  imageUpload: s3
    .image()
    .maxFileSize('5MB')
    .formats(['jpeg', 'jpg', 'png', 'webp'])
    .middleware(async ({ file, metadata }) => { 
      // Add authentication and user context
      return {
        ...metadata,
        userId: 'user-123',
        uploadedAt: new Date().toISOString(),
      }
    }),

  documentUpload: s3
    .file()
    .maxFileSize('10MB')
    .types(['application/pdf', 'text/plain'])
    .paths({
      prefix: 'documents',
    }),
})

// Export the handler
export const { GET, POST } = s3Router.handlers; 

Schema Builders

Image Schema

Image Schema Configuration
s3.image()
  .maxFileSize('5MB') 
  .formats(['jpeg', 'jpg', 'png', 'webp', 'gif'])
  .dimensions({ minWidth: 100, maxWidth: 2000 })
  .quality(0.8) // JPEG quality

File Schema

File Schema Configuration
s3.file()
  .maxFileSize('10MB') 
  .types(['application/pdf', 'text/plain', 'application/json'])
  .extensions(['pdf', 'txt', 'json'])

Object Schema (Multiple Files)

Object Schema Configuration
s3.object({
  images: s3.image().maxFileSize('5MB').maxFiles(5), 
  documents: s3.file().maxFileSize('10MB').maxFiles(2),
  thumbnail: s3.image().maxFileSize('1MB').maxFiles(1),
})

Route Configuration

Middleware

Add authentication, validation, and metadata:

Middleware Example
.middleware(async ({ file, metadata, req }) => { 
  // Authentication
  const user = await authenticateUser(req)
  if (!user) {
    throw new Error('Authentication required') 
  }

  // File validation
  if (file.size > 10 * 1024 * 1024) {
    throw new Error('File too large')
  }

  // Return enriched metadata
  return {
    ...metadata,      // Client-provided metadata (e.g., albumId, tags)
    userId: user.id,  
    userRole: user.role,
    uploadedAt: new Date().toISOString(),
    ipAddress: req.headers.get('x-forwarded-for'),
  }
})

Client Metadata Support: The metadata parameter contains data sent from the client via uploadFiles(files, metadata). This allows passing UI context like album selections, tags, or form data. The middleware can then enrich this client metadata with server-side data like authenticated user information.

Example with Client Metadata:

// Client component
const { uploadFiles } = upload.imageUpload();

uploadFiles(files, {
  albumId: 'vacation-2025',
  tags: ['beach', 'sunset'],
  visibility: 'private'
});

// Server middleware receives and validates
.middleware(async ({ req, metadata }) => {
  const user = await authenticateUser(req);
  
  // Validate client-provided albumId
  if (metadata?.albumId) {
    const album = await db.albums.findFirst({
      where: { 
        id: metadata.albumId, 
        userId: user.id  // Ensure user owns the album
      }
    });
    if (!album) throw new Error('Album not found or access denied');
  }
  
  return {
    // Client metadata (validated)
    albumId: metadata?.albumId,
    tags: metadata?.tags || [],
    visibility: metadata?.visibility || 'private',
    
    // Server metadata (trusted)
    userId: user.id,          // From auth, NOT from client
    role: user.role,          // From auth, NOT from client
    uploadedAt: new Date().toISOString()
  };
})

Security Warning: Client metadata is UNTRUSTED user input. Always validate and never trust client-provided identity claims (userId, role, permissions, etc.). Extract identity from authenticated sessions on the server.

Path Configuration

Control where files are stored:

.paths({
  // Simple prefix
  prefix: 'user-uploads',
  
  // Custom path generation
  generateKey: (ctx) => {
    const { file, metadata, routeName } = ctx
    const userId = metadata.userId
    const timestamp = Date.now()
    
    return `${routeName}/${userId}/${timestamp}/${file.name}`
  },
  
  // Simple suffix
  suffix: 'processed',
})

Lifecycle Hooks

React to upload events:

.onUploadStart(async ({ file, metadata }) => {
  console.log(`Starting upload: ${file.name}`)
  
  // Log to analytics
  await analytics.track('upload_started', {
    userId: metadata.userId,
    filename: file.name,
    fileSize: file.size,
  })
})

.onUploadComplete(async ({ file, url, metadata }) => {
  console.log(`Upload complete: ${file.name} -> ${url}`)
  
  // Save to database
  await db.files.create({
    filename: file.name,
    url,
    userId: metadata.userId,
    size: file.size,
    contentType: file.type,
    uploadedAt: new Date(),
  })
  
  // Send notification
  await notificationService.send({
    userId: metadata.userId,
    type: 'upload_complete',
    message: `${file.name} uploaded successfully`,
  })
})

.onUploadError(async ({ file, error, metadata }) => {
  console.error(`Upload failed: ${file.name}`, error)
  
  // Log error
  await errorLogger.log({
    operation: 'file_upload',
    error: error.message,
    userId: metadata.userId,
    filename: file.name,
  })
})

Advanced Examples

E-commerce Product Images

const productRouter = s3.createRouter({
  productImages: s3
    .image()
    .maxFileSize('5MB')
    .formats(['jpeg', 'jpg', 'png', 'webp'])
    .dimensions({ minWidth: 800, maxWidth: 2000 })
    .middleware(async ({ metadata, req }) => {
      const user = await authenticateUser(req)
      const productId = metadata.productId
      
      // Verify user owns the product
      const product = await db.products.findFirst({
        where: { id: productId, ownerId: user.id }
      })
      
      if (!product) {
        throw new Error('Product not found or access denied')
      }
      
      return {
        ...metadata,
        userId: user.id,
        productId,
        productName: product.name,
      }
    })
    .paths({
      generateKey: (ctx) => {
        const { metadata } = ctx
        return `products/${metadata.productId}/images/${Date.now()}.jpg`
      }
    })
    .onUploadComplete(async ({ url, metadata }) => {
      // Update product with new image
      await db.products.update({
        where: { id: metadata.productId },
        data: {
          images: {
            push: url
          }
        }
      })
    }),

  productDocuments: s3
    .file()
    .maxFileSize('10MB')
    .types(['application/pdf'])
    .paths({
      prefix: 'product-docs',
    })
    .onUploadComplete(async ({ url, metadata }) => {
      await db.productDocuments.create({
        productId: metadata.productId,
        documentUrl: url,
        type: 'specification',
      })
    }),
})

User Profile System

const profileRouter = s3.createRouter({
  avatar: s3
    .image()
    .maxFileSize('2MB')
    .formats(['jpeg', 'jpg', 'png'])
    .dimensions({ minWidth: 100, maxWidth: 500 })
    .middleware(async ({ req }) => {
      const user = await authenticateUser(req)
      return { userId: user.id, type: 'avatar' }
    })
    .paths({
      generateKey: (ctx) => {
        return `users/${ctx.metadata.userId}/avatar.jpg`
      }
    })
    .onUploadComplete(async ({ url, metadata }) => {
      // Update user profile
      await db.users.update({
        where: { id: metadata.userId },
        data: { avatarUrl: url }
      })
      
      // Invalidate cache
      await cache.del(`user:${metadata.userId}`)
    }),

  documents: s3
    .object({
      resume: s3.file().maxFileSize('5MB').types(['application/pdf']).maxFiles(1),
      portfolio: s3.file().maxFileSize('10MB').maxFiles(3),
    })
    .middleware(async ({ req }) => {
      const user = await authenticateUser(req)
      return { userId: user.id }
    })
    .paths({
      prefix: 'user-documents',
    }),
})

Client-Side Usage

Once you have your router set up, use it from the client:

// components/FileUploader.tsx
import { useUploadRoute } from 'pushduck'

export function FileUploader() {
  const { upload, isUploading } = useUploadRoute('imageUpload')

  const handleUpload = async (files: FileList) => {
    try {
      const results = await upload(files, {
        // This metadata will be passed to middleware
        productId: 'product-123',
        category: 'main-images',
      })
      
      console.log('Upload complete:', results)
    } catch (error) {
      console.error('Upload failed:', error)
    }
  }

  return (
    <div>
      <input
        type="file"
        multiple
        accept="image/*"
        onChange={(e) => e.target.files && handleUpload(e.target.files)}
        disabled={isUploading}
      />
      {isUploading && <p>Uploading...</p>}
    </div>
  )
}

Type Safety

The router provides full TypeScript support:

// Types are automatically inferred
type RouterType = typeof s3Router

// Get route names
type RouteNames = keyof RouterType // 'imageUpload' | 'documentUpload'

// Get route input types
type ImageUploadInput = InferRouteInput<RouterType['imageUpload']>

// Get route metadata types
type ImageUploadMetadata = InferRouteMetadata<RouterType['imageUpload']>