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.zipUpload API Route
Since ElysiaJs 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: env.storage.endpoint,
publicUrl: env.storage.publicUrl,
forcePathStyle: env.storage.forcePathStyle,
region: env.storage.region,
bucket: env.storage.bucket,
path: env.storage.path,
accessKeyId: env.storage.accessKeyId,
secretAccessKey: env.storage.secretAccessKey,
}
}
}The adapter works with any S3-compatible provider. The bucket must exist and allow public reads before the first upload (the adapter never creates buckets or policies):
MinIO is not a special case: the same variables serve all three providers — only the endpoint and the public base change.
# MinIO (local dev — bucket created by minio-init in docker-compose.dev.yml)
STORAGE_ACCESS_KEY_ID=minioadmin
STORAGE_SECRET_ACCESS_KEY=minioadmin
STORAGE_DEFAULT_REGION=us-east-1 # MinIO accepts any region
STORAGE_ENDPOINT=http://localhost:9000
STORAGE_PUBLIC_URL=http://localhost:9000/my-bucket
STORAGE_BUCKET=my-bucket
# Cloudflare R2 (enable Public access on the bucket in the dashboard)
STORAGE_ACCESS_KEY_ID=<r2-access-key>
STORAGE_SECRET_ACCESS_KEY=<r2-secret-key>
STORAGE_DEFAULT_REGION=auto
STORAGE_ENDPOINT=https://<account_id>.r2.cloudflarestorage.com
STORAGE_PUBLIC_URL=https://pub-xxxx.r2.dev # or your custom domain
STORAGE_BUCKET=my-bucket
# AWS S3 (public bucket policy + Block Public Access disabled)
# STORAGE_ENDPOINT stays empty — the SDK uses the default AWS endpoints.
STORAGE_ACCESS_KEY_ID=<aws-access-key>
STORAGE_SECRET_ACCESS_KEY=<aws-secret-key>
STORAGE_DEFAULT_REGION=us-east-1
STORAGE_PUBLIC_URL=https://my-bucket.s3.us-east-1.amazonaws.com
STORAGE_BUCKET=my-bucketSTORAGE_PUBLIC_URL is the full public base of file URLs (url = base/key).
When empty, it falls back to STORAGE_ENDPOINT + /bucket (fine for local
MinIO). STORAGE_FORCE_PATH_STYLE defaults to true when STORAGE_ENDPOINT
is set (MinIO/R2) and false otherwise (AWS).
Omitting STORAGE_ACCESS_KEY_ID/STORAGE_SECRET_ACCESS_KEY is valid on AWS: the
adapter skips explicit credentials and the SDK's default chain takes over
(AWS_* env vars, EC2/ECS instance role, IRSA on EKS).
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: minioadminCreate 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.
Provision the Bucket
The adapter never creates buckets or policies — provision them per provider before the first upload:
- MinIO (dev/self-hosted):
minio-initin the compose files creates the bucket and enables anonymous downloads (mc anonymous set download). - Cloudflare R2: create the bucket and enable Public access (r2.dev subdomain or a custom domain) in the dashboard.
- AWS S3: create the bucket, disable Block Public Access and attach a
public-read bucket policy (
s3:GetObjectfor*) via console or IaC.
Files are served by direct public URL. For sensitive data, consider a private bucket + signed URLs (not implemented yet).
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