Skip to main content
Shipr includes a production-ready file upload system backed by Convex storage.

Features

  • Convex storage - Direct uploads with signed URLs
  • Server-side validation - MIME type and size verification
  • Rate limiting - Prevents upload abuse
  • Ownership checks - Users can only access their own files
  • Filename sanitization - Safe storage of user-provided names

Upload Flow

Convex uses a secure 3-step upload process:
1. Client requests upload URL

2. Client uploads file directly to Convex storage

3. Client saves metadata with storageId

Using the Upload Hook

The easiest way to upload files is using the useFileUpload hook:
~/workspace/source/src/hooks/use-file-upload.ts
import { useFileUpload } from "@/hooks/use-file-upload";

function FileUploadComponent() {
  const { upload, isUploading, error, progress } = useFileUpload({
    maxSize: 10 * 1024 * 1024, // 10 MB
    onSuccess: (storageId) => {
      console.log("File uploaded:", storageId);
    },
    onError: (error) => {
      console.error("Upload failed:", error);
    },
  });

  const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (file) {
      await upload(file);
    }
  };

  return (
    <div>
      <input type="file" onChange={handleFileChange} disabled={isUploading} />
      {isUploading && <p>Uploading... ({progress})</p>}
      {error && <p className="text-red-500">{error}</p>}
    </div>
  );
}
The useFileUpload hook handles all three steps automatically: generating the upload URL, uploading the file, and saving metadata to Convex.

Manual Upload Steps

For more control, you can implement the 3-step flow manually:

Step 1: Generate Upload URL

const generateUploadUrl = useMutation(api.files.generateUploadUrl);
const uploadUrl = await generateUploadUrl();

Step 2: Upload File

const result = await fetch(uploadUrl, {
  method: "POST",
  body: file,
  headers: { "Content-Type": file.type },
});

const { storageId } = await result.json();

Step 3: Save Metadata

const saveFile = useMutation(api.files.saveFile);
await saveFile({
  storageId,
  fileName: file.name,
  mimeType: file.type,
  size: file.size,
});

Convex Schema

~/workspace/source/convex/schema.ts
files: defineTable({
  storageId: v.id("_storage"),
  userId: v.id("users"),
  fileName: v.string(),
  mimeType: v.string(),
  size: v.number(),
})
  .index("by_user_id", ["userId"])
  .index("by_storage_id", ["storageId"])
FieldTypeDescription
storageIdId<"_storage">Reference to stored file blob
userIdId<"users">File owner
fileNamestringSanitized filename
mimeTypestringMIME type (e.g., “image/png”)
sizenumberFile size in bytes

Security Validation

The saveFile mutation validates files server-side to prevent abuse:
~/workspace/source/convex/files.ts
export const saveFile = mutation({
  args: {
    storageId: v.id("_storage"),
    fileName: v.string(),
    mimeType: v.string(),
    size: v.number(),
  },
  handler: async (ctx, args) => {
    const user = await requireCurrentUser(ctx);

    // SECURITY: Verify actual file metadata from storage
    const storedFile = await ctx.db.system.get(args.storageId);
    if (!storedFile) {
      throw new Error("Storage ID not found");
    }

    const actualSize = storedFile.size;
    const actualMimeType = storedFile.contentType ?? args.mimeType;

    // Validate actual file size (not client-provided)
    if (actualSize > FILE_STORAGE_LIMITS.maxFileSizeBytes) {
      await ctx.storage.delete(args.storageId);
      throw new Error("File too large");
    }

    // Validate MIME type
    if (!ALLOWED_MIME_TYPES.has(actualMimeType)) {
      await ctx.storage.delete(args.storageId);
      throw new Error("File type not allowed");
    }

    // Sanitize filename
    const sanitizedName = args.fileName
      .replace(/[\/\\]/g, "_")
      .replace(/[<>:"|?*]/g, "_")
      .slice(0, 255);

    return await ctx.db.insert("files", {
      storageId: args.storageId,
      userId: user._id,
      fileName: sanitizedName,
      mimeType: actualMimeType,
      size: actualSize,
    });
  },
});
Always validate file metadata from ctx.db.system.get() to prevent client-side spoofing of file size and MIME type.

Configuration

File upload settings are centralized in src/lib/files/config.ts:
~/workspace/source/src/lib/files/config.ts
export const FILE_STORAGE_LIMITS = {
  maxFileSizeBytes: 10 * 1024 * 1024, // 10 MB
  maxFilesPerUser: 100,
} as const;

export const FILE_UPLOAD_RATE_LIMITS = {
  image: {
    maxUploadsPerWindow: readNonNegativeIntEnv(
      "FILE_IMAGE_UPLOAD_RATE_LIMIT_MAX_UPLOADS",
      10,
    ),
    windowMs: readNonNegativeIntEnv(
      "FILE_IMAGE_UPLOAD_RATE_LIMIT_WINDOW_MS",
      60_000,
    ),
  },
} as const;

Supported File Types

~/workspace/source/src/lib/files/config.ts
export const FILE_TYPE_CATALOG = [
  { mimeType: "image/jpeg", label: "JPEG", extensions: [".jpg", ".jpeg"] },
  { mimeType: "image/png", label: "PNG", extensions: [".png"] },
  { mimeType: "image/gif", label: "GIF", extensions: [".gif"] },
  { mimeType: "image/webp", label: "WebP", extensions: [".webp"] },
  { mimeType: "image/svg+xml", label: "SVG", extensions: [".svg"] },
  { mimeType: "application/pdf", label: "PDF", extensions: [".pdf"] },
  { mimeType: "text/plain", label: "Text", extensions: [".txt"] },
  { mimeType: "text/csv", label: "CSV", extensions: [".csv"] },
  { mimeType: "application/json", label: "JSON", extensions: [".json"] },
  // ... Word, Excel documents
] as const;

Rate Limiting

Image uploads are rate-limited per user to prevent abuse:
~/workspace/source/convex/files.ts
async function enforceImageUploadRateLimit(
  ctx: MutationCtx,
  userId: Id<"users">,
) {
  const { maxUploadsPerWindow, windowMs } = FILE_UPLOAD_RATE_LIMITS.image;

  if (maxUploadsPerWindow <= 0 || windowMs <= 0) {
    return; // Rate limiting disabled
  }

  const windowStart = Date.now() - windowMs;
  const userFiles = await ctx.db
    .query("files")
    .withIndex("by_user_id", (q) => q.eq("userId", userId))
    .collect();

  const recentImageUploads = userFiles.filter(
    (file) =>
      isImageMimeType(file.mimeType) && file._creationTime >= windowStart,
  );

  if (recentImageUploads.length >= maxUploadsPerWindow) {
    throw new Error("Image upload rate limit reached");
  }
}

Environment Variables

.env.example
# File upload rate limiting (set to 0 to disable)
FILE_IMAGE_UPLOAD_RATE_LIMIT_MAX_UPLOADS=10
FILE_IMAGE_UPLOAD_RATE_LIMIT_WINDOW_MS=60000
VariableDefaultDescription
FILE_IMAGE_UPLOAD_RATE_LIMIT_MAX_UPLOADS10Max image uploads per window
FILE_IMAGE_UPLOAD_RATE_LIMIT_WINDOW_MS60000Rate limit window (1 minute)
Set either variable to 0 to disable image upload rate limiting.

Retrieving Files

Get signed URLs for user files:
const files = useQuery(api.files.getUserFiles);

{files?.map((file) => (
  <img key={file._id} src={file.url} alt={file.fileName} />
))}

Deleting Files

Delete both the storage blob and database record:
const deleteFile = useMutation(api.files.deleteFile);

await deleteFile({ fileId: file._id });
The mutation automatically:
  1. Verifies file ownership
  2. Deletes the blob from Convex storage
  3. Removes the database record

Adding File Types

To support additional file types:
  1. Add the MIME type to FILE_TYPE_CATALOG in src/lib/files/config.ts
  2. Both UI validation and backend enforcement update automatically
export const FILE_TYPE_CATALOG = [
  // ... existing types
  { 
    mimeType: "video/mp4", 
    label: "MP4", 
    extensions: [".mp4"] 
  },
] as const;

Build docs developers (and LLMs) love