Storage Package
File and image upload field types with pluggable storage providers for OpenSaas Stack.
Overview
The storage package provides:
- File Upload Fields - Generic file uploads with metadata storage
- Image Upload Fields - Image uploads with automatic transformations
- Multiple Storage Backends - Local filesystem, AWS S3, Vercel Blob, and more
- Automatic Image Optimization - Server-side image transformations with sharp
- Type-Safe Metadata - JSON-backed metadata storage in your database
- Flexible Validation - File size, MIME type, and extension validation
Installation
pnpm add @opensaas/stack-storage sharp
For S3 storage:
pnpm add @opensaas/stack-storage-s3
For Vercel Blob storage:
pnpm add @opensaas/stack-storage-vercel
Quick Start
1. Configure Storage Providers
// opensaas.config.ts
import { config, list } from '@opensaas/stack-core'
import { localStorage } from '@opensaas/stack-storage'
import { file, image } from '@opensaas/stack-storage/fields'
export default config({
storage: {
documents: localStorage({
uploadDir: './public/uploads/documents',
serveUrl: '/uploads/documents',
}),
},
lists: {
User: list({
fields: {
avatar: image({
storage: 'documents',
transformations: {
thumbnail: { width: 100, height: 100, fit: 'cover' },
profile: { width: 400, height: 400, fit: 'cover' },
},
validation: {
maxFileSize: 5 * 1024 * 1024, // 5MB
acceptedMimeTypes: ['image/jpeg', 'image/png', 'image/webp'],
},
}),
},
}),
},
})
2. Create Upload API Route
// app/api/upload/route.ts
import { NextRequest, NextResponse } from 'next/server.js'
import config from '@/opensaas.config'
import { uploadFile, uploadImage, parseFileFromFormData } from '@opensaas/stack-storage/runtime'
export async function POST(request: NextRequest) {
try {
const formData = await request.formData()
const storageProvider = formData.get('storage') as string
const fieldType = formData.get('fieldType') as 'file' | 'image'
const fileData = await parseFileFromFormData(formData, 'file')
if (!fileData) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
}
if (fieldType === 'image') {
const metadata = await uploadImage(config, storageProvider, fileData, {
validation: {
maxFileSize: 5 * 1024 * 1024,
acceptedMimeTypes: ['image/jpeg', 'image/png', 'image/webp'],
},
transformations: {
thumbnail: { width: 100, height: 100, fit: 'cover' },
},
})
return NextResponse.json(metadata)
} else {
const metadata = await uploadFile(config, storageProvider, fileData, {
validation: {
maxFileSize: 10 * 1024 * 1024,
},
})
return NextResponse.json(metadata)
}
} catch (error) {
console.error('Upload error:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Upload failed' },
{ status: 500 },
)
}
}
3. Use in Admin UI
File and image fields work automatically in the admin UI. The fields appear in forms with drag-and-drop upload interfaces.
Field Types
File Field
Generic file uploads that store metadata as JSON:
import { file } from '@opensaas/stack-storage/fields'
fields: {
resume: file({
storage: 'documents',
validation: {
maxFileSize: 10 * 1024 * 1024, // 10MB
acceptedMimeTypes: ['application/pdf'],
acceptedExtensions: ['.pdf'],
},
}),
}
Image Field
Image uploads with automatic transformations:
import { image } from '@opensaas/stack-storage/fields'
fields: {
avatar: image({
storage: 'avatars',
transformations: {
thumbnail: {
width: 100,
height: 100,
fit: 'cover',
format: 'webp',
quality: 80,
},
large: {
width: 1200,
height: 1200,
fit: 'inside',
format: 'jpeg',
quality: 90,
},
},
validation: {
maxFileSize: 5 * 1024 * 1024,
acceptedMimeTypes: ['image/jpeg', 'image/png', 'image/webp'],
},
}),
}
Storage Providers
Local Filesystem
Store files on the local filesystem:
import { localStorage } from '@opensaas/stack-storage'
storage: {
documents: localStorage({
uploadDir: './public/uploads/documents',
serveUrl: '/uploads/documents',
generateUniqueFilenames: true, // default
}),
}
Best for: Development, small deployments, or when files need to be served from the same server.
AWS S3
Store files in Amazon S3 or S3-compatible services:
import { s3Storage } from '@opensaas/stack-storage-s3'
storage: {
avatars: s3Storage({
bucket: 'my-bucket',
region: 'us-east-1',
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
pathPrefix: 'avatars', // optional
acl: 'public-read', // default: 'private'
customDomain: 'https://cdn.example.com', // optional
}),
}
Best for: Production deployments, scalable storage, CDN integration.
Vercel Blob
Store files in Vercel Blob storage:
import { vercelBlobStorage } from '@opensaas/stack-storage-vercel'
storage: {
images: vercelBlobStorage({
token: process.env.BLOB_READ_WRITE_TOKEN,
pathPrefix: 'images',
public: true, // default
cacheControl: 'public, max-age=31536000, immutable',
}),
}
Best for: Vercel deployments, automatic CDN distribution, simplified setup.
Image Transformations
Automatically generate multiple image variants with different sizes and formats:
avatar: image({
storage: 'avatars',
transformations: {
thumbnail: {
width: 100,
height: 100,
fit: 'cover', // crop to fill
format: 'webp',
quality: 80,
},
medium: {
width: 400,
height: 400,
fit: 'cover',
format: 'webp',
},
large: {
width: 1200,
height: 1200,
fit: 'inside', // scale to fit within bounds
format: 'jpeg',
quality: 90,
},
},
})
Fit Modes
cover- Crop to fill dimensions (default)contain- Scale to fit within dimensions (letterbox)fill- Stretch to fill dimensionsinside- Scale down to fit within dimensionsoutside- Scale up to cover dimensions
Supported Formats
jpeg/jpgpngwebp(recommended for web)avif(modern, best compression)giftiff
Validation
Control what files can be uploaded:
file({
storage: 'documents',
validation: {
maxFileSize: 10 * 1024 * 1024, // 10MB in bytes
acceptedMimeTypes: ['application/pdf', 'application/msword'],
acceptedExtensions: ['.pdf', '.doc', '.docx'],
},
})
Metadata Storage
Files and images store metadata as JSON in your database. The Prisma schema uses the Json type:
model User {
id String @id @default(cuid())
avatar Json? // ImageMetadata
resume Json? // FileMetadata
}
File Metadata
{
filename: "1234567890-abc123.pdf",
originalFilename: "resume.pdf",
url: "https://bucket.s3.amazonaws.com/...",
mimeType: "application/pdf",
size: 245678,
uploadedAt: "2025-10-31T12:00:00Z",
storageProvider: "documents",
metadata: { /* custom metadata */ }
}
Image Metadata
Extends FileMetadata with image-specific fields:
{
filename: "1234567890-abc123.jpg",
originalFilename: "avatar.jpg",
url: "https://bucket.s3.amazonaws.com/...",
mimeType: "image/jpeg",
size: 123456,
width: 1200,
height: 800,
uploadedAt: "2025-10-31T12:00:00Z",
storageProvider: "avatars",
transformations: {
thumbnail: {
url: "https://...",
width: 100,
height: 100,
size: 5678
},
large: {
url: "https://...",
width: 1200,
height: 1200,
size: 98765
}
}
}
Runtime Utilities
Upload Helpers
import { uploadFile, uploadImage, deleteFile, deleteImage } from '@opensaas/stack-storage/runtime'
// Upload file
const metadata = await uploadFile(
config,
'documents',
{ file, buffer },
{
validation: { maxFileSize: 10 * 1024 * 1024 },
},
)
// Upload image with transformations
const imageMetadata = await uploadImage(
config,
'avatars',
{ file, buffer },
{
validation: { maxFileSize: 5 * 1024 * 1024 },
transformations: {
thumbnail: { width: 100, height: 100, fit: 'cover' },
},
},
)
// Delete file
await deleteFile(config, 'documents', metadata.filename)
// Delete image (includes all transformations)
await deleteImage(config, imageMetadata)
Validation Utilities
import { validateFile, formatFileSize, getMimeType } from '@opensaas/stack-storage/utils'
const validation = validateFile(
{ size: file.size, name: file.name, type: file.type },
{ maxFileSize: 10 * 1024 * 1024, acceptedMimeTypes: ['application/pdf'] },
)
if (!validation.valid) {
console.error(validation.error)
}
// Format file sizes
formatFileSize(1024) // "1 KB"
formatFileSize(1048576) // "1 MB"
// Get MIME type from filename
getMimeType('document.pdf') // "application/pdf"
Parse FormData
import { parseFileFromFormData } from '@opensaas/stack-storage/utils'
const fileData = await parseFileFromFormData(formData, 'file')
if (fileData) {
const { file, buffer } = fileData
// Upload file...
}
Custom Storage Providers
Implement the StorageProvider interface to create custom storage backends:
import type { StorageProvider, UploadOptions, UploadResult } from '@opensaas/stack-storage'
export class CustomStorageProvider implements StorageProvider {
async upload(
file: Buffer | Uint8Array,
filename: string,
options?: UploadOptions,
): Promise<UploadResult> {
// Your upload logic
return {
filename: 'generated-filename.jpg',
url: 'https://your-cdn.com/file.jpg',
size: file.length,
contentType: options?.contentType || 'application/octet-stream',
}
}
async download(filename: string): Promise<Buffer> {
// Your download logic
}
async delete(filename: string): Promise<void> {
// Your delete logic
}
getUrl(filename: string): string {
return `https://your-cdn.com/${filename}`
}
async getSignedUrl(filename: string, expiresIn?: number): Promise<string> {
// Optional: signed URLs for private files
}
}
Common Patterns
Multiple Storage Providers
Use different storage providers for different file types:
storage: {
avatars: s3Storage({
bucket: 'user-avatars',
region: 'us-east-1',
acl: 'public-read',
}),
documents: localStorage({
uploadDir: './private/documents',
serveUrl: '/api/files',
}),
videos: vercelBlobStorage({
token: process.env.BLOB_TOKEN,
pathPrefix: 'videos',
}),
}
Private Files
Serve files through an authenticated route:
// app/api/files/[filename]/route.ts
import { createStorageProvider } from '@opensaas/stack-storage/runtime'
import config from '@/opensaas.config'
export async function GET(request: NextRequest, { params }: { params: { filename: string } }) {
const session = await getSession()
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const provider = createStorageProvider(config, 'documents')
const buffer = await provider.download(params.filename)
return new NextResponse(buffer, {
headers: {
'Content-Type': 'application/octet-stream',
'Content-Disposition': `attachment; filename="${params.filename}"`,
},
})
}
Environment-Specific Storage
Use different storage providers for development and production:
const storage =
process.env.NODE_ENV === 'production'
? {
avatars: s3Storage({
bucket: process.env.AWS_BUCKET,
region: process.env.AWS_REGION,
}),
}
: {
avatars: localStorage({
uploadDir: './public/uploads',
serveUrl: '/uploads',
}),
}
export default config({
storage,
// ...
})
Security
- Server-side validation - All validation happens server-side in upload routes
- MIME type validation - Prevents file type spoofing
- File size limits - Prevents DoS attacks
- Access control - Implement in upload routes
- Signed URLs - For private S3 files (optional)
Next Steps
- Storage Setup Guide - Detailed setup instructions for all storage providers
- Custom Fields Guide - Learn about creating custom field types
- Hooks System - Add custom behavior to file uploads