Nitrofy LogoNitrofy
Como funcionaBeneficiosIntegracoesPlanosDuvidas
Comece agora
Nitrofy Logo
Comecar
Introduction
QuickstartEnvironment SetupRunning LocallyFirst Deploy
Organizations & TenancyAuthentication & SessionsRoles & PermissionsControllers & ProceduresJobs & QueuesPlugin ManagerContent LayerBuilt-in MCP ServerBillingNotificationsFile StorageEmailWebhooksAPI KeysSEOData FetchingDesign System
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 SaaS Boilerplate 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 Igniter.js 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

  • Authentication & Sessions - User context for file uploads
  • Organizations and Tenancy - Organization-scoped file storage
  • Jobs & Queues - Background processing for file operations
  • Environment Variables - Storage configuration
  • Deployment - Production storage setup

API Reference

Storage Service Methods

Prop

Type

Upload Hook API

Prop

Type

Storage Configuration

Prop

Type

Notifications

In-app and email notifications with templates, real-time streaming, and user preferences.

Email

Transactional email system with React Email templates, multiple adapters, and real-time preview.

On this page

OverviewArchitectureStorage Provider SystemS3-Compatible AdapterContext-Based OrganizationUpload API RouteSetting Up File StorageConfigure Storage ProviderStart MinIO for DevelopmentInitialize Storage ServiceConfigure Bucket PoliciesBackend Usage (Procedures & Controllers)Direct Storage OperationsFile Management OperationsIntegration with Business LogicFrontend Usage (Client-side)Using the Upload HookAvatar Upload ComponentCustom Upload ComponentsFile Storage Data StructureStorageProviderFileUpload Hook StatePractical ExamplesBackend: User Avatar ManagementBackend: Organization Document StorageFrontend: Multi-File Upload with ProgressTroubleshootingBest PracticesSee AlsoAPI ReferenceStorage Service MethodsUpload Hook APIStorage Configuration
Nitrofy LogoNitrofy

Automatize o envio e a cobrança dos seus contratos

© 2026 Nitrofy, All rights reserved