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 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-bucketStart 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.
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