Overview
Use Vercel Blob for file storage with preset-based validation and scope-based access control. Files are stored with program/project isolation.
What it is
Vercel Blob storage with abstracted provider pattern, file presets, and permission checking utilities.
Why we use it
Built-in CDN, preset validation (size/type limits), scope-based access control, and soft delete support.
When to use
User file uploads, document storage, image assets, or any binary data that needs to be persisted and accessed.
Key Features
- File presets with size and type validation
- Scope-based access (PUBLIC/PROGRAM/PROJECT)
- Automatic path prefixing by scope
- Soft delete with restore capability
Quick Start
Uploading Files
Basic file upload with preset validation.
// Upload a file to Vercel Blob
import { uploadFile } from '@/features/files';
const result = await uploadFile({
file: formData.get('file') as File,
preset: 'image', // image | document | video | audio
purpose: 'DESIGN', // FilePurpose enum (DESIGN, MEDIA, etc.)
accessLevel: 'PROGRAM', // PUBLIC | PROGRAM | PROJECT
userId: currentUserId, // Uploader's user ID
scopeType: 'PROGRAM', // PROGRAM | PROJECT (required with scopeId)
scopeId: programId,
});
// result = { id, url, fileName, fileType, fileSizeBytes, category, ... }Patterns
File Presets
Size limits and allowed types by preset.
// File presets with size limits and allowed types
import { FILE_PRESETS } from '@/lib/storage';
FILE_PRESETS.image = {
accept: {
'image/png': ['.png'],
'image/jpeg': ['.jpg', '.jpeg'],
'image/gif': ['.gif'],
'image/webp': ['.webp'],
'image/svg+xml': ['.svg'],
},
maxSizeBytes: 10 * 1024 * 1024, // 10MB
category: 'IMAGE',
};
FILE_PRESETS.document = {
accept: {
'application/pdf': ['.pdf'],
'application/vnd.openxmlformats-officedocument...': ['.docx', '.xlsx'],
'text/markdown': ['.md'],
'text/csv': ['.csv'],
// ... and more
},
maxSizeBytes: 25 * 1024 * 1024, // 25MB
category: 'DOCUMENT',
};
FILE_PRESETS.video = {
accept: { 'video/mp4': ['.mp4'], 'video/webm': ['.webm'], 'video/quicktime': ['.mov'] },
maxSizeBytes: 500 * 1024 * 1024, // 500MB
category: 'MEDIA',
};
FILE_PRESETS.audio = {
accept: { 'audio/mpeg': ['.mp3'], 'audio/wav': ['.wav'], 'audio/ogg': ['.ogg'], 'audio/webm': ['.webm'] },
maxSizeBytes: 100 * 1024 * 1024, // 100MB
category: 'MEDIA',
};Access Control
Permission checks for upload and access.
// Access control levels
import { checkUploadPermission, checkFileAccess, checkDeletePermission } from '@/lib/storage';
// Before upload - check permission (scopeType, scopeId, userId)
const canUpload = await checkUploadPermission('PROGRAM', programId, userId);
if (!canUpload) throw new AuthorizationError('Cannot upload to this program');
// Before download - check access (file object, user object)
const canAccess = await checkFileAccess(
{ id: file.id, accessLevel: file.accessLevel, scopeType: file.scopeType, scopeId: file.scopeId, uploadedById: file.uploadedById },
{ id: userId }
);
if (!canAccess) throw new AuthorizationError('Cannot access this file');
// Before delete - check delete permission
const canDelete = await checkDeletePermission(file, userId);
if (!canDelete) throw new AuthorizationError('Cannot delete this file');
// Access rules:
// - PUBLIC: Anyone can access
// - PROGRAM: Must be program member
// - PROJECT: Must be project participant
// - Uploader always has access to their own files
// - Delete: Uploader OR scope admin (OWNER/BUILDER role)Critical: Always check permissions before upload and access. The uploader always has access to their own files.
Upload API Route
Complete file upload endpoint pattern.
// API route for file upload
import { validateRequest } from '@/lib/validation';
import { handleError } from '@/lib/errors';
import { uploadFile, uploadFileSchema } from '@/features/files';
import { withAuth } from '@/lib/middleware';
export const POST = withAuth(async (request, { auth }) => {
try {
const formData = await request.formData();
const file = formData.get('file') as File;
const metadata = JSON.parse(formData.get('metadata') as string);
const validation = validateRequest(metadata, uploadFileSchema);
if (!validation.success) return validation.error;
const result = await uploadFile({
file,
preset: validation.data.preset,
purpose: validation.data.purpose,
accessLevel: validation.data.accessLevel,
userId: auth.user.id,
scopeType: validation.data.scopeType,
scopeId: validation.data.scopeId,
});
return NextResponse.json({ ok: true, data: result });
} catch (error) {
return handleError(error);
}
});Watch Out
Accepting files without preset validation
Don't
// Accepting any file type
const file = formData.get('file');
await uploadToBlob(file); // No validation!Do
// Validate against preset
import { validateFile } from '@/lib/storage';
const file = formData.get('file') as File;
const validation = validateFile(file, 'image');
if (!validation.valid) {
// validation.errorKey and validation.errorParams available for i18n
throw new ValidationError(validation.errorKey ?? 'files.errors.invalidFile');
}
await uploadFile({ file, preset: 'image', purpose: 'DESIGN', ... });- Missing permission checks on upload/download
- Not enforcing file size limits
- Making files public when they should be scoped