Development

File Storage

S3-compatible file storage system with secure uploads, context-based organization, and real-time progress tracking.

By the end of this guide, you'll have set up a comprehensive file storage system with S3-compatible providers, secure upload handling, context-based file organization, and real-time progress tracking for your SaaS application.

Overview

The ExzosSphere includes a robust file storage system built on S3-compatible providers (AWS S3, MinIO, Cloudflare R2, etc.) that supports secure uploads, context-based organization, and real-time progress tracking. Key features include:

  • Multi-provider support: AWS S3, MinIO, Cloudflare R2, and other S3-compatible services
  • Context-based organization: Files organized by context (user, organization, public) and identifiers
  • Secure uploads: Direct-to-storage uploads with proper authentication and validation
  • Real-time progress: Upload progress tracking with state management
  • Automatic file management: UUID-based naming, extension handling, and cleanup operations
  • Type-safe interfaces: Full TypeScript support with proper error handling
  • Hook-based frontend: Simple React hooks for file uploads with state management
  • Public access control: Configurable bucket policies for public/private access

The system integrates seamlessly with the authentication layer and provides both backend and frontend APIs for complete file management.

Architecture

Storage Provider System

The storage system is built around the StorageProvider class with adapter pattern support:

// src/@saas-boilerplate/providers/storage/storage.provider.ts
const storageProvider = StorageProvider.initialize({
  adapter: CompatibleS3StorageAdapter,
  credentials: AppConfig.providers.storage,
  contexts: ['user', 'organization', 'public'] as const,
  onFileUploadSuccess: (file, url) => {
    console.log(`File uploaded: ${file.name} -> ${url}`)
  }
})

S3-Compatible Adapter

The primary adapter supports all S3-compatible services with automatic bucket management:

// src/@saas-boilerplate/providers/storage/adapters/compatible-s3-storage.adapter.ts
export const CompatibleS3StorageAdapter = StorageProvider.adapter((options) => ({
  upload: async (context, identifier, file) => {
    const filename = `${randomUUID()}.${file.name.split('.').pop()}`
    const path = `${context}/${identifier}/${filename}`
    
    await s3Client.send(new PutObjectCommand({
      Bucket: options.credentials.bucket,
      Key: path,
      Body: await convertFileToBuffer(file),
      ContentType: file.type,
      ACL: 'public-read'
    }))
    
    return {
      context,
      identifier,
      name: filename,
      extension: file.name.split('.').pop() || '',
      size: file.size,
      url: `${options.credentials.endpoint}/${options.credentials.bucket}/${path}`
    }
  }
}))

Context-Based Organization

Files are organized hierarchically by context and identifier:

bucket/
├── user/
│   ├── user-123/
│   │   ├── abc123def.jpg
│   │   └── def456ghi.png
│   └── user-456/
│       └── jkl789mno.pdf
├── organization/
│   ├── org-789/
│   │   ├── logo.png
│   │   └── banner.jpg
│   └── org-101/
│       └── document.pdf
└── public/
    ├── shared-001/
    │   └── image.jpg
    └── shared-002/
        └── file.zip

Upload API Route

Since ExzosAtom doesn't support file uploads yet, a Next.js API route handles uploads:

// src/app/(api)/api/storage/route.tsx
export const POST = async (request: NextRequest) => {
  const form = await request.formData()
  const file = form.get('file') as File
  const context = form.get('context') as string
  const identifier = form.get('identifier') as string

  const uploadedFile = await storage.upload(context, identifier, file)
  return NextResponse.json(uploadedFile)
}

Setting Up File Storage

Configure Storage Provider

Set up your storage provider credentials in the server configuration:

// src/config/boilerplate.config.server.ts
export const AppConfig = {
  providers: {
    storage: {
      provider: 'S3',
      endpoint: process.env.STORAGE_ENDPOINT,
      region: process.env.STORAGE_REGION,
      bucket: process.env.STORAGE_BUCKET,
      path: process.env.STORAGE_PATH,
      accessKeyId: process.env.STORAGE_ACCESS_KEY_ID,
      secretAccessKey: process.env.STORAGE_SECRET_ACCESS_KEY,
    }
  }
}

For local development with MinIO:

# Environment variables
STORAGE_ENDPOINT=http://localhost:9000
STORAGE_ACCESS_KEY_ID=minioadmin
STORAGE_SECRET_ACCESS_KEY=minioadmin
STORAGE_REGION=us-east-1
STORAGE_BUCKET=my-bucket

Start MinIO for Development

For local development, run MinIO using Docker:

# Using Docker
docker run -d \
  -p 9000:9000 -p 9001:9001 \
  --name minio \
  -e "MINIO_ACCESS_KEY=minioadmin" \
  -e "MINIO_SECRET_KEY=minioadmin" \
  -v ~/minio/data:/data \
  quay.io/minio/minio server /data --console-address ":9001"

# Access MinIO console at http://localhost:9001
# Username: minioadmin
# Password: minioadmin

Create a bucket named my-bucket in the MinIO console.

Initialize Storage Service

The storage service is automatically initialized with your configuration:

// src/services/storage.ts
export const storage = StorageProvider.initialize({
  adapter: CompatibleS3StorageAdapter,
  credentials: AppConfig.providers.storage,
  contexts: ['user', 'organization', 'public'] as const,
})

The storage service is available in the Igniter context through the services object.

Configure Bucket Policies

For public access, the adapter automatically creates bucket policies. For private buckets, configure appropriate policies in your storage provider.

Backend Usage (Procedures & Controllers)

Direct Storage Operations

Use the storage service directly in your procedures and controllers:

// In a controller or procedure
import { storage } from '@/services/storage'

export const uploadFile = igniter.procedure({
  handler: async ({ context, request }) => {
    const session = await context.auth.getSession({
      requirements: 'authenticated'
    })

    // Upload file directly
    const uploadedFile = await storage.upload(
      'user',
      session.user.id,
      request.file // File object from request
    )

    return response.success({
      url: uploadedFile.url,
      name: uploadedFile.name,
      size: uploadedFile.size
    })
  }
})

File Management Operations

Perform various file operations through the storage service:

// List all files for a user
const userFiles = await storage.list('user', userId)

// Delete a specific file
await storage.delete(fileUrl)

// Remove all files for a context/identifier
await storage.prune('organization', orgId)

Integration with Business Logic

Integrate file uploads with your business logic:

// User avatar upload procedure
export const updateUserAvatar = igniter.procedure({
  handler: async ({ context, request }) => {
    const session = await context.auth.getSession({
      requirements: 'authenticated'
    })

    // Upload new avatar
    const avatarFile = await storage.upload('user', session.user.id, request.file)

    // Update user profile
    await context.database.user.update({
      where: { id: session.user.id },
      data: { image: avatarFile.url }
    })

    // Clean up old avatar if exists
    if (session.user.image) {
      await storage.delete(session.user.image)
    }

    return response.success({ avatarUrl: avatarFile.url })
  }
})

Frontend Usage (Client-side)

Using the Upload Hook

The useUpload hook provides a simple interface for file uploads with state management:

// src/@saas-boilerplate/hooks/use-upload.ts
import { useUpload } from '@/@saas-boilerplate/hooks/use-upload'

function FileUploader({ userId }: { userId: string }) {
  const { upload, data: files } = useUpload({
    context: {
      type: 'user',
      identifier: userId
    },
    onFileStateChange: (fileState) => {
      console.log('Upload state:', fileState.state)
      if (fileState.state === 'uploaded') {
        console.log('File uploaded:', fileState.url)
      }
    }
  })

  const handleFileSelect = async (file: File) => {
    try {
      await upload(file)
    } catch (error) {
      console.error('Upload failed:', error)
    }
  }

  return (
    <div>
      <input
        type="file"
        onChange={(e) => e.target.files?.[0] && handleFileSelect(e.target.files[0])}
      />
      {files.map(file => (
        <div key={file.name}>
          {file.name} - {file.state}
          {file.uploading && <span>Uploading...</span>}
          {file.url && <img src={file.url} alt={file.name} />}
        </div>
      ))}
    </div>
  )
}

Avatar Upload Component

Use the AvatarUploadInput component for profile pictures:

// src/components/ui/avatar-upload-input.tsx
import { AvatarUploadInput } from '@/components/ui/avatar-upload-input'

function UserProfileForm() {
  const { session } = useAuth()

  return (
    <AvatarUploadInput
      context="users"
      id={session.user.id}
      onChange={(url) => {
        // Update user profile with new avatar URL
        updateUserProfile({ image: url })
      }}
      onStateChange={async (file) => {
        if (file.state === 'uploaded') {
          toast.success('Avatar updated successfully!')
        }
      }}
      value={session.user.image}
      placeholder={session.user.name}
    />
  )
}

Custom Upload Components

Build custom upload components using the hook:

function DocumentUploader({ organizationId }: { organizationId: string }) {
  const [uploadedFiles, setUploadedFiles] = useState<FileState[]>([])

  const { upload } = useUpload({
    context: {
      type: 'organization',
      identifier: organizationId
    },
    onFileStateChange: (fileState) => {
      setUploadedFiles(prev => {
        const existing = prev.find(f => f.file === fileState.file)
        if (existing) {
          return prev.map(f => f.file === fileState.file ? fileState : f)
        }
        return [...prev, fileState]
      })

      if (fileState.state === 'uploaded') {
        // Save file metadata to database
        saveDocumentMetadata({
          name: fileState.name,
          url: fileState.url,
          size: fileState.size,
          organizationId
        })
      }
    }
  })

  const handleDrop = useCallback((files: File[]) => {
    files.forEach(file => upload(file))
  }, [upload])

  return (
    <div
      onDrop={(e) => {
        e.preventDefault()
        handleDrop(Array.from(e.dataTransfer.files))
      }}
      onDragOver={(e) => e.preventDefault()}
      className="border-2 border-dashed p-8 text-center"
    >
      <p>Drop files here or click to upload</p>
      <input
        type="file"
        multiple
        onChange={(e) => e.target.files && handleDrop(Array.from(e.target.files))}
        className="hidden"
      />

      {uploadedFiles.map(file => (
        <div key={file.name} className="mt-2">
          {file.name} ({file.state})
          {file.state === 'error' && <span className="text-red-500">Failed</span>}
        </div>
      ))}
    </div>
  )
}

File Storage Data Structure

StorageProviderFile

Prop

Type

Upload Hook State

Prop

Type

Practical Examples

Backend: User Avatar Management

Complete avatar upload and management system:

// User avatar controller
export const updateAvatar = igniter.mutation({
  use: [AuthFeatureProcedure()],
  body: z.object({ file: z.any() }), // File will be handled by route
  handler: async ({ context, request }) => {
    const session = await context.auth.getSession({
      requirements: 'authenticated'
    })

    // Upload new avatar
    const avatarFile = await context.services.storage.upload(
      'user',
      session.user.id,
      request.body.file
    )

    // Update user record
    const updatedUser = await context.database.user.update({
      where: { id: session.user.id },
      data: { image: avatarFile.url }
    })

    // Clean up old avatar
    if (session.user.image && session.user.image !== avatarFile.url) {
      await context.services.storage.delete(session.user.image)
    }

    return response.success({
      user: updatedUser,
      avatarUrl: avatarFile.url
    })
  }
})

Backend: Organization Document Storage

Handle document uploads for organizations:

// Organization document upload
export const uploadDocument = igniter.mutation({
  use: [AuthFeatureProcedure()],
  body: z.object({
    file: z.any(),
    documentType: z.enum(['contract', 'invoice', 'report'])
  }),
  handler: async ({ context, request }) => {
    const session = await context.auth.getSession({
      requirements: 'authenticated',
      roles: ['admin', 'owner']
    })

    // Upload document
    const documentFile = await context.services.storage.upload(
      'organization',
      session.organization.id,
      request.body.file
    )

    // Save document metadata
    const document = await context.database.document.create({
      data: {
        name: documentFile.name,
        url: documentFile.url,
        size: documentFile.size,
        type: request.body.documentType,
        organizationId: session.organization.id,
        uploadedById: session.user.id
      }
    })

    return response.success(document)
  }
})

Frontend: Multi-File Upload with Progress

Advanced file upload component with progress tracking:

function MultiFileUploader({ context, identifier }: UploadProps) {
  const [files, setFiles] = useState<FileState[]>([])
  const { upload } = useUpload({
    context: { type: context, identifier },
    onFileStateChange: (fileState) => {
      setFiles(prev => {
        const existingIndex = prev.findIndex(f => f.file === fileState.file)
        if (existingIndex >= 0) {
          const updated = [...prev]
          updated[existingIndex] = fileState
          return updated
        }
        return [...prev, fileState]
      })
    }
  })

  const handleFilesSelected = async (selectedFiles: FileList) => {
    const uploadPromises = Array.from(selectedFiles).map(file => upload(file))
    await Promise.allSettled(uploadPromises)
  }

  const completedFiles = files.filter(f => f.state === 'uploaded')
  const failedFiles = files.filter(f => f.state === 'error')

  return (
    <div className="space-y-4">
      <input
        type="file"
        multiple
        onChange={(e) => e.target.files && handleFilesSelected(e.target.files)}
        accept="image/*,application/pdf"
      />

      <div className="space-y-2">
        {files.map((file, index) => (
          <div key={index} className="flex items-center space-x-2 p-2 border rounded">
            <div className="flex-1">
              <p className="text-sm font-medium">{file.name}</p>
              <p className="text-xs text-muted-foreground">
                {(file.size / 1024 / 1024).toFixed(2)} MB
              </p>
            </div>
            <div className="text-sm">
              {file.state === 'uploading' && <span className="text-blue-500">Uploading...</span>}
              {file.state === 'uploaded' && <span className="text-green-500">✓ Complete</span>}
              {file.state === 'error' && <span className="text-red-500">✗ Failed</span>}
            </div>
          </div>
        ))}
      </div>

      {completedFiles.length > 0 && (
        <p className="text-green-600">
          {completedFiles.length} files uploaded successfully
        </p>
      )}

      {failedFiles.length > 0 && (
        <p className="text-red-600">
          {failedFiles.length} files failed to upload
        </p>
      )}
    </div>
  )
}

Troubleshooting

Best Practices

See Also

API Reference

Storage Service Methods

Prop

Type

Upload Hook API

Prop

Type

Storage Configuration

Prop

Type