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).
Kit configures Vercel Blob with the EU Frankfurt region for data storage. All uploaded files are stored within the EU, which is required for GDPR compliance when handling European user data. The region is set in the storage service configuration — no additional setup needed.
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
When you create a Blob store through the Vercel dashboard and link it to your project, the
BLOB_READ_WRITE_TOKEN is automatically set in your Vercel environment variables. You only need to add it to apps/boilerplate/.env.local for local development.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
| Check | Rule | Error Message |
|---|---|---|
| File size | Max 4.5 MB (Vercel Blob limit) | "File size exceeds maximum of 4.5MB" |
| File type | JPEG, PNG, GIF, WebP, PDF only | "File type {type} is not allowed" |
| Extension | Blocks .exe, .bat, .cmd, .sh, .ps1, .dll | "This file type is not allowed for security reasons" |
| Filename | Server-side sanitization via sanitizeFilename() | N/A (sanitized silently) |
To allow additional file types, update the
DEFAULT_ALLOWED_TYPES array in apps/boilerplate/src/lib/storage/validation.ts and the corresponding MIME type mapping in apps/boilerplate/src/lib/storage/vercel-blob.ts. Both need to match.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:
| Feature | Free | Basic | Pro | Enterprise |
|---|---|---|---|---|
| Upload access | No | Yes | Yes | Yes |
| Max file size | — | 2 MB | 4.5 MB | 4.5 MB |
| Upload UI | — | Standard file picker | Drag & drop | Drag & drop |
| File types | — | Images, PDF | Images, PDF | Images, 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).Tier detection reads the user's active subscription from the database and maps the Lemon Squeezy variant ID to a tier name (
free, basic, pro, enterprise). The same tier logic is used for AI rate limiting and file upload access.UI Components
Kit includes 5 upload-related components in
apps/boilerplate/src/components/upload/:| Component | Description |
|---|---|
DropZoneUpload | Drag & drop zone with visual feedback, file type icons, and upload progress bar. Available for Pro and Enterprise tiers. |
StandardFileInput | Traditional file input with a styled button. Used for Basic tier. |
FilePreview | Displays uploaded file with thumbnail (images) or icon (PDFs), file name, size, and a delete button. |
TierAwareUpload | Wrapper that conditionally renders DropZoneUpload or StandardFileInput based on the user's subscription tier. Shows an upgrade prompt for free users. |
SettingsUploadSection | Complete 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:
| Layer | Protection |
|---|---|
| Filename sanitization | sanitizeFilename() removes path traversal, special characters, and null bytes |
| Extension blocking | Rejects executable extensions (.exe, .bat, .cmd, .sh, .ps1, .dll) |
| MIME type validation | Only allows configured content types (images, PDF) |
| Size limits | Hard 4.5 MB limit (Vercel Blob maximum) |
| User ownership | All file operations verify userId — users can only access their own files |
| Rate limiting | Upload endpoint protected by withRateLimit('upload') — 10 req/hour per user, 20/hour per IP |
| Zod validation | Server-side parameter validation with centralized schemas |
| Orphan cleanup | If database save fails after upload, the blob is automatically deleted |
Files uploaded to Vercel Blob have public URLs by default (
access: 'public'). Anyone with the URL can access the file. If you need private files, change the access mode to 'private' and use signed URLs — see the Vercel Blob documentation.Environment Variables
| Variable | Required | Purpose |
|---|---|---|
BLOB_READ_WRITE_TOKEN | Yes | Vercel Blob storage token for uploads and deletions |
Key Files
| File | Purpose |
|---|---|
apps/boilerplate/src/lib/storage/vercel-blob.ts | Blob SDK wrapper — upload, delete, list, storage stats |
apps/boilerplate/src/lib/storage/validation.ts | File validation — size limits, allowed types, security checks |
apps/boilerplate/src/lib/storage/file-utils.ts | Utility functions — ID generation, path construction |
apps/boilerplate/src/lib/storage/types.ts | TypeScript types for upload results, validation |
apps/boilerplate/src/app/api/upload/route.ts | POST upload endpoint with Zod validation |
apps/boilerplate/src/app/api/files/[id]/route.ts | GET 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.ts | Zod schema for upload parameter validation |
apps/boilerplate/src/lib/security/sanitization.ts | Filename sanitization function |