File Storage

File uploads with Vercel Blob — tier-gated access, drag & drop, validation, and database tracking

Kit ships with a complete file upload system powered by Vercel Blob. The system includes tier-gated access (upload permissions based on subscription plan), drag & drop with progress tracking, file validation with security checks, and database tracking for every uploaded file.
This page covers setup, the upload flow, validation, and file management. For API rate limiting on upload routes, see Caching & Redis. For security details, see Security Overview.

How It Works

Every file upload flows through validation, blob storage, and database tracking:
UI Component (DropZoneUpload / StandardFileInput)
    |
    |--- Client-side validation (size, type, extension)
    |--- Shows upload progress
    |
    v
API Route (POST /api/upload)
    |--- 1. Rate limit check (withRateLimit('upload'))
    |--- 2. Zod schema validation (filename, filesize, filetype)
    |--- 3. Filename sanitization
    |--- 4. User authentication (Clerk)
    |
    v
Storage Service (vercel-blob.ts)
    |--- Generates unique file ID
    |--- Creates user-scoped path: users/{userId}/{fileId}/{filename}
    |--- Uploads to Vercel Blob (public access)
    |
    v
Database Record (Prisma File model)
    |--- Stores file metadata (url, size, contentType)
    |--- Links file to user (userId foreign key)
    |--- Returns file record to client

Setup

1

Enable Vercel Blob in your project

In the Vercel dashboard, go to your project's Storage tab and create a new Blob store. Vercel Blob is included in all Vercel plans (free tier: 1 GB storage, 1 GB bandwidth/month).
2

Set the environment variable

Copy the read/write token from the Vercel Blob store settings and add it to apps/boilerplate/.env.local:
bash
BLOB_READ_WRITE_TOKEN=vercel_blob_rw_your_token_here

Upload Flow

The upload API route validates parameters with Zod, authenticates the user via Clerk, then streams the file to Vercel Blob:
src/app/api/upload/route.ts — POST Handler (Validation)
export const POST = withRateLimit('upload', async (request: NextRequest) => {
  let uploadResult: Awaited<ReturnType<typeof uploadToBlob>> | null = null
  let userId: string | null = null
  let clerkUserId: string | null = null
  let filename: string | null = null
  let filesize: string | null = null
  let filetype: string | null = null

  try {
    // Get and validate query params
    const { searchParams } = new URL(request.url)
    const rawFilename = searchParams.get('filename')
    const rawFilesize = searchParams.get('filesize')
    const rawFiletype = searchParams.get('filetype')

    // Validate upload parameters using centralized schema
    const validation = uploadFileSchema.safeParse({
      filename: rawFilename,
      filesize: rawFilesize,
      filetype: rawFiletype,
    })

    if (!validation.success) {
      console.error('Upload validation failed:', validation.error.flatten())
      return NextResponse.json(
        {
          error: 'Invalid upload parameters',
          details: validation.error.flatten().fieldErrors,
        },
        { status: 400 }
      )
    }

    // Extract validated and sanitized data
    const validatedData = validation.data
    filename = sanitizeFilename(validatedData.filename)
    filesize = validatedData.filesize.toString()
    filetype = validatedData.filetype
The storage service generates a unique file path scoped to the user and uploads via the Vercel Blob SDK:
src/lib/storage/vercel-blob.ts — uploadToBlob
export async function uploadToBlob(
  userId: string,
  file: File | Blob | ReadableStream | ArrayBuffer,
  filename: string,
  fileSize?: number
): Promise<FileUploadResult> {
  const fileId = generateFileId()
  const pathname = generateFilePath(userId, fileId, filename)

  try {
    // Get file extension to determine content type
    const extension = filename.toLowerCase().split('.').pop()
    let contentType = 'application/octet-stream'

    if (extension) {
      const mimeTypes: Record<string, string> = {
        jpg: 'image/jpeg',
        jpeg: 'image/jpeg',
        png: 'image/png',
        gif: 'image/gif',
        webp: 'image/webp',
        pdf: 'application/pdf',
      }
      contentType = mimeTypes[extension] || contentType
    }

    // Upload to Vercel Blob
    const blob = await put(pathname, file, {
      access: 'public',
      contentType,
    })

    // Use provided size or default to 0
    // Note: Vercel Blob doesn't return size in response for streaming uploads
    const size = fileSize || 0

    return {
      id: fileId,
      url: blob.url,
      pathname,
      originalName: filename,
      size, // Size from parameter or 0
      contentType,
    }
  } catch (error) {
    console.error('Failed to upload to Vercel Blob:', error)
    throw new Error('Failed to upload file. Please try again.')
  }
}

File Validation

Files are validated on both the client (UI components) and server (API route) with three checks:
src/lib/storage/validation.ts — Validation Rules
import type { FileValidationOptions, FileValidationResult } from './types'

// Max file size: 4.5MB (Vercel Blob limit)
export const MAX_FILE_SIZE = 4.5 * 1024 * 1024

// Default allowed file types
export const DEFAULT_ALLOWED_TYPES = [
  'image/jpeg',
  'image/jpg',
  'image/png',
  'image/gif',
  'image/webp',
  'application/pdf',
]

/**
 * Validate a file before upload
 */
export function validateFile(
  file: File | Blob,
  options: FileValidationOptions = {}
): FileValidationResult {
  const { maxSize = MAX_FILE_SIZE, allowedTypes = DEFAULT_ALLOWED_TYPES } =
    options

  // Check file size
  if (file.size > maxSize) {
    const maxSizeMB = (maxSize / (1024 * 1024)).toFixed(1)
    return {
      valid: false,
      error: `File size exceeds maximum of ${maxSizeMB}MB`,
    }
  }

  // Check file type
  if (allowedTypes.length > 0 && !allowedTypes.includes(file.type)) {
    return {
      valid: false,
      error: `File type ${file.type} is not allowed. Allowed types: ${allowedTypes.join(', ')}`,
    }
  }

  // Additional validation for file names if it's a File object
  if ('name' in file) {
    const fileName = file.name

    // Check for suspicious file extensions
    const suspiciousExtensions = ['.exe', '.bat', '.cmd', '.sh', '.ps1', '.dll']
    const hasSupiciousExtension = suspiciousExtensions.some((ext) =>
      fileName.toLowerCase().endsWith(ext)
    )

    if (hasSupiciousExtension) {
      return {
        valid: false,
        error: 'This file type is not allowed for security reasons',
      }
    }
  }

  return { valid: true }
}

Validation Rules

CheckRuleError Message
File sizeMax 4.5 MB (Vercel Blob limit)"File size exceeds maximum of 4.5MB"
File typeJPEG, PNG, GIF, WebP, PDF only"File type {type} is not allowed"
ExtensionBlocks .exe, .bat, .cmd, .sh, .ps1, .dll"This file type is not allowed for security reasons"
FilenameServer-side sanitization via sanitizeFilename()N/A (sanitized silently)

Tier-Gated Access

File upload access is gated by subscription tier. Free users cannot upload files, and higher tiers get larger size limits and better UI:
FeatureFreeBasicProEnterprise
Upload accessNoYesYesYes
Max file size2 MB4.5 MB4.5 MB
Upload UIStandard file pickerDrag & dropDrag & drop
File typesImages, PDFImages, PDFImages, PDF
The TierAwareUpload component handles this automatically — it checks the user's subscription tier and renders the appropriate upload component (or nothing for free users).

UI Components

Kit includes 5 upload-related components in apps/boilerplate/src/components/upload/:
ComponentDescription
DropZoneUploadDrag & drop zone with visual feedback, file type icons, and upload progress bar. Available for Pro and Enterprise tiers.
StandardFileInputTraditional file input with a styled button. Used for Basic tier.
FilePreviewDisplays uploaded file with thumbnail (images) or icon (PDFs), file name, size, and a delete button.
TierAwareUploadWrapper that conditionally renders DropZoneUpload or StandardFileInput based on the user's subscription tier. Shows an upgrade prompt for free users.
SettingsUploadSectionComplete upload section for the settings page — combines tier-aware upload with file list and management.

Usage Example

tsx
import { TierAwareUpload } from '@/components/upload/tier-aware-upload'

export default function SettingsPage() {
  return (
    <TierAwareUpload
      onUploadComplete={(file) => {
        console.log('Uploaded:', file.url)
      }}
    />
  )
}

Managing Files

Uploaded files can be retrieved and deleted via the /api/files/[id] endpoint. Both operations verify user ownership — a user can only access their own files:
src/app/api/files/[id]/route.ts — GET (File Details)
export async function GET(request: NextRequest, { params }: Params) {
  try {
    const fileId = params.id

    // Get current user
    let userId: string | null = null

    if (shouldBypassClerk()) {
      userId = 'test-user-id'
    } else {
      const user = await currentUser()
      if (!user) {
        return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
      }

      const dbUser = await prisma.user.findUnique({
        where: { clerkId: user.id },
      })

      if (!dbUser) {
        return NextResponse.json({ error: 'User not found' }, { status: 404 })
      }

      userId = dbUser.id
    }

    // Get file from database
    const file = await prisma.file.findFirst({
      where: {
        id: fileId,
        userId,
      },
    })

    if (!file) {
      return NextResponse.json({ error: 'File not found' }, { status: 404 })
    }

    return NextResponse.json({
      id: file.id,
      url: file.url,
      originalName: file.originalName,
      size: file.size,
      contentType: file.contentType,
      createdAt: file.createdAt,
      updatedAt: file.updatedAt,
    })
  } catch (error) {
    console.error('Get file error:', error)
    return NextResponse.json({ error: 'Failed to get file' }, { status: 500 })
  }
}

DELETE — File Deletion

The DELETE handler removes the file from both Vercel Blob and the database. If blob deletion fails, the database record is still removed to prevent orphaned records:
src/app/api/files/[id]/route.ts — DELETE
export async function DELETE(request: NextRequest, { params }: Params) {
  try {
    const fileId = params.id

    // Get current user
    let userId: string | null = null

    if (shouldBypassClerk()) {
      userId = 'test-user-id'
    } else {
      const user = await currentUser()
      if (!user) {
        return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
      }

      const dbUser = await prisma.user.findUnique({
        where: { clerkId: user.id },
      })

      if (!dbUser) {
        return NextResponse.json({ error: 'User not found' }, { status: 404 })
      }

      userId = dbUser.id
    }

    // Get file from database
    const file = await prisma.file.findFirst({
      where: {
        id: fileId,
        userId,
      },
    })

    if (!file) {
      return NextResponse.json({ error: 'File not found' }, { status: 404 })
    }

    // Delete from Vercel Blob
    const deleteResult = await deleteFromBlob(file.url)

    if (!deleteResult.success) {
      console.error('Failed to delete from Vercel Blob:', deleteResult.error)
      // Continue with database deletion even if blob deletion fails
      // This prevents orphaned records
    }

    // Delete from database
    await prisma.file.delete({
      where: { id: fileId },
    })

    return NextResponse.json({
      success: true,
      message: 'File deleted successfully',
    })
  } catch (error) {
    console.error('Delete file error:', error)
    return NextResponse.json(
      { error: 'Failed to delete file' },
      { status: 500 }
    )
  }
}

Database Schema

Every uploaded file is tracked in the File model:
File
├── id            # Auto-generated UUID
├── userId        # Foreign key to User (owner)
├── url           # Vercel Blob public URL
├── pathname      # Storage path: users/{userId}/{fileId}/{filename}
├── originalName  # Original filename (sanitized)
├── contentType   # MIME type (image/jpeg, application/pdf, etc.)
├── size          # File size in bytes
├── metadata      # JSON — extensible metadata field
├── createdAt     # Upload timestamp
└── updatedAt     # Last modification
Files are scoped to users via the userId foreign key. All queries include a userId filter to enforce ownership.

Security

The upload system includes multiple security layers:
LayerProtection
Filename sanitizationsanitizeFilename() removes path traversal, special characters, and null bytes
Extension blockingRejects executable extensions (.exe, .bat, .cmd, .sh, .ps1, .dll)
MIME type validationOnly allows configured content types (images, PDF)
Size limitsHard 4.5 MB limit (Vercel Blob maximum)
User ownershipAll file operations verify userId — users can only access their own files
Rate limitingUpload endpoint protected by withRateLimit('upload') — 10 req/hour per user, 20/hour per IP
Zod validationServer-side parameter validation with centralized schemas
Orphan cleanupIf database save fails after upload, the blob is automatically deleted

Environment Variables

VariableRequiredPurpose
BLOB_READ_WRITE_TOKENYesVercel Blob storage token for uploads and deletions

Key Files

FilePurpose
apps/boilerplate/src/lib/storage/vercel-blob.tsBlob SDK wrapper — upload, delete, list, storage stats
apps/boilerplate/src/lib/storage/validation.tsFile validation — size limits, allowed types, security checks
apps/boilerplate/src/lib/storage/file-utils.tsUtility functions — ID generation, path construction
apps/boilerplate/src/lib/storage/types.tsTypeScript types for upload results, validation
apps/boilerplate/src/app/api/upload/route.tsPOST upload endpoint with Zod validation
apps/boilerplate/src/app/api/files/[id]/route.tsGET and DELETE endpoints for file management
apps/boilerplate/src/components/upload/5 UI components (DropZone, StandardInput, Preview, TierAware, Settings)
apps/boilerplate/src/lib/validations/api-schemas.tsZod schema for upload parameter validation
apps/boilerplate/src/lib/security/sanitization.tsFilename sanitization function