File Upload & Attachments
Complete guide to implementing file uploads and attachments with S3 presigned URLs
File Upload & Attachments
ProductReady provides a comprehensive file upload system using S3 presigned URLs for secure, scalable file handling. This guide covers both the general upload infrastructure and specific implementations.
Architecture: Files are uploaded directly from client to S3 without passing through your server, saving bandwidth and improving performance.
Overview
The upload system consists of three main components:
- tRPC Attachments Router - Server-side presigned URL generation and validation
- Upload Hooks - Client-side React hooks for easy integration
- Storage Utilities - Shared utilities from
sharelib/storage
Upload Flow
Client Server S3
| | |
|--requestUploadUrl------>| |
| | |
|<--presignedURL + key----| |
| | |
|--PUT file------------------------------>| |
| | |
|<--200 OK--------------------------------| |
| | |
|--confirmUpload (opt)--->| |
|<--publicURL-------------| |Quick Start
1. Environment Setup
Add S3 credentials to your .env file:
# S3 Storage Configuration
AWS_REGION=us-east-1
AWS_S3_BUCKET=your-bucket-name
AWS_ACCESS_KEY_ID=your-access-key
AWS_SECRET_ACCESS_KEY=your-secret-keySecurity: Never commit .env files to version control. Use environment variables in production.
2. Basic Image Upload
Use the useImageUpload hook for RichEditor or other image upload scenarios:
import { useImageUpload } from "~/hooks/useImageUpload";
export function MyComponent() {
const { handleImageUpload, uploading } = useImageUpload({
context: "post",
onError: (error) => console.error("Upload failed:", error),
});
const onFileSelect = async (file: File) => {
try {
const publicUrl = await handleImageUpload(file);
console.log("Image uploaded:", publicUrl);
} catch (error) {
// Error already handled by onError callback
}
};
return (
<div>
<input
type="file"
accept="image/*"
onChange={(e) => e.target.files?.[0] && onFileSelect(e.target.files[0])}
disabled={uploading}
/>
{uploading && <p>Uploading...</p>}
</div>
);
}3. Attachment Upload (Multiple Files)
Use useAttachmentUpload for handling multiple attachments (e.g., in chat or forms):
import { useAttachmentUpload } from "~/hooks/useAttachmentUpload";
import type { FileUIPart } from "ai";
export function ChatComponent() {
const { handleFileUpload, uploading } = useAttachmentUpload({
context: "general",
onUploadStart: () => console.log("Upload started"),
onUploadComplete: () => console.log("Upload completed"),
});
const onFilesSelected = async (files: FileList) => {
// Convert FileList to FileUIPart[] format
const fileParts: FileUIPart[] = Array.from(files).map(file => ({
type: "file",
filename: file.name,
mimeType: file.type,
url: URL.createObjectURL(file), // blob URL
}));
// Upload to S3 and get public URLs
const uploadedFiles = await handleFileUpload(fileParts);
console.log("Uploaded files:", uploadedFiles);
};
return (
<input
type="file"
multiple
onChange={(e) => e.target.files && onFilesSelected(e.target.files)}
disabled={uploading}
/>
);
}Upload Hooks API
useImageUpload
Hook for uploading images (e.g., in RichEditor, profile pictures, blog posts).
Options
interface UseImageUploadOptions {
/** Upload context for organizing files in S3 */
context?: "post" | "general";
/** Called on upload error */
onError?: (error: Error) => void;
}Returns
{
handleImageUpload: (file: File) => Promise<string>; // Returns public URL
uploading: boolean; // Upload state
}Example: Profile Picture Upload
import { useImageUpload } from "~/hooks/useImageUpload";
import { useState } from "react";
export function ProfilePictureUpload() {
const [avatarUrl, setAvatarUrl] = useState("");
const { handleImageUpload, uploading } = useImageUpload({
context: "general",
onError: (error) => alert(error.message),
});
const onAvatarSelect = async (file: File) => {
const url = await handleImageUpload(file);
setAvatarUrl(url);
// Save URL to user profile...
};
return (
<div>
{avatarUrl && <img src={avatarUrl} alt="Avatar" />}
<input
type="file"
accept="image/*"
onChange={(e) => e.target.files?.[0] && onAvatarSelect(e.target.files[0])}
disabled={uploading}
/>
</div>
);
}useAttachmentUpload
Hook for uploading multiple attachments (e.g., chat attachments, document uploads).
Options
interface UseAttachmentUploadOptions {
/** Upload context for organizing files in S3 */
context?: "post" | "general";
/** Called when upload starts */
onUploadStart?: () => void;
/** Called when upload completes */
onUploadComplete?: () => void;
/** Called on error */
onError?: (error: Error) => void;
}Returns
{
handleFileUpload: (files: FileUIPart[]) => Promise<FileUIPart[]>;
uploading: boolean;
}Example: Multi-file Upload
import { useAttachmentUpload } from "~/hooks/useAttachmentUpload";
export function DocumentUploader() {
const [attachments, setAttachments] = useState<string[]>([]);
const { handleFileUpload, uploading } = useAttachmentUpload({
context: "general",
onUploadComplete: () => console.log("All files uploaded!"),
});
const onFilesSelected = async (files: FileList) => {
const fileParts = Array.from(files).map(file => ({
type: "file" as const,
filename: file.name,
mimeType: file.type,
url: URL.createObjectURL(file),
}));
const uploaded = await handleFileUpload(fileParts);
setAttachments(prev => [...prev, ...uploaded.map(f => f.url)]);
};
return (
<div>
<input
type="file"
multiple
onChange={(e) => e.target.files && onFilesSelected(e.target.files)}
disabled={uploading}
/>
{uploading && <p>Uploading...</p>}
<ul>
{attachments.map((url, i) => (
<li key={i}><a href={url} target="_blank" rel="noopener noreferrer">File {i + 1}</a></li>
))}
</ul>
</div>
);
}tRPC Attachments Router
The server-side router handles presigned URL generation and validation.
Endpoints
attachments.requestUploadUrl
Request a presigned upload URL for direct S3 upload.
Input:
{
fileName: string; // Original filename (max 255 chars)
mimeType: string; // File MIME type (e.g., "image/png")
sizeBytes: number; // File size in bytes (max 10MB)
context?: "post" | "general"; // Optional context for organizing files
}Output:
{
uploadUrl: string; // Presigned PUT URL (expires in 1 hour)
storageKey: string; // S3 key for the file
publicUrl: string; // Presigned GET URL (expires in 24 hours)
expiresIn: number; // Upload URL expiration in seconds
}Usage:
import { trpc } from "~/lib/trpc/client";
const requestUploadUrl = trpc.attachments.requestUploadUrl.useMutation();
const uploadFile = async (file: File) => {
// Step 1: Request presigned URL
const { uploadUrl, publicUrl } = await requestUploadUrl.mutateAsync({
fileName: file.name,
mimeType: file.type,
sizeBytes: file.size,
context: "post",
});
// Step 2: Upload to S3
const response = await fetch(uploadUrl, {
method: "PUT",
body: file,
headers: { "Content-Type": file.type },
});
if (!response.ok) {
throw new Error("Upload failed");
}
// Step 3: Use public URL
return publicUrl;
};attachments.confirmUpload (Optional)
Confirm upload for tracking/auditing. This is optional—the file is already accessible via publicUrl.
Input:
{
storageKey: string; // S3 key from requestUploadUrl
fileName: string; // Original filename
mimeType: string; // File MIME type
sizeBytes: number; // File size
context?: "post" | "general";
}Output:
{
success: boolean;
storageKey: string;
publicUrl: string; // Fresh presigned URL (24 hours)
}Storage Utilities
The sharelib/storage package provides reusable utilities for presigned URL workflows.
Import
import {
uploadToS3WithPresignedUrl,
extractFileExtension,
validateFileSize,
validateMimeType,
formatBytes,
} from "sharelib/storage";uploadToS3WithPresignedUrl
Upload file content to S3 using a presigned URL.
const response = await uploadToS3WithPresignedUrl(
presignedUrl,
file, // Buffer | Blob | string | ArrayBuffer
"image/png"
);
if (!response.ok) {
throw new Error("Upload failed");
}extractFileExtension
Extract file extension from filename.
extractFileExtension("document.pdf"); // Returns: "pdf"
extractFileExtension("archive.tar.gz"); // Returns: "gz"
extractFileExtension("no-extension"); // Returns: ""validateFileSize
Validate file size against maximum limit.
const MAX_SIZE = 10 * 1024 * 1024; // 10MB
validateFileSize(file.size, MAX_SIZE); // Throws error if exceedsvalidateMimeType
Validate MIME type against allowed types.
validateMimeType("image/png", [
"image/*", // Wildcard pattern
"application/pdf",
]); // Throws error if not allowedformatBytes
Format bytes to human-readable size.
formatBytes(1536); // "1.5 KB"
formatBytes(1048576); // "1 MB"
formatBytes(5242880, 0); // "5 MB"Security & Validation
File Size Limits
- Default Maximum: 10MB per file
- Enforcement: Server-side validation before presigned URL generation
- Customization: Modify
MAX_FILE_SIZEinsrc/server/routers/attachments.ts
// Server-side validation
if (input.sizeBytes > MAX_FILE_SIZE) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `File size exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit`,
});
}MIME Type Validation
Only images are allowed by default:
const allowedMimeTypes = [
"image/jpeg",
"image/jpg",
"image/png",
"image/gif",
"image/webp",
"image/svg+xml",
];To support documents, uncomment in src/server/routers/attachments.ts:
// Uncomment to allow PDFs and Word documents
// "application/pdf",
// "application/msword",
// "application/vnd.openxmlformats-officedocument.wordprocessingml.document",Authentication
All upload endpoints require authentication:
export const attachmentsRouter = createTRPCRouter({
requestUploadUrl: protectedProcedure // Requires logged-in user
.input(...)
.mutation(...),
});Storage Organization
Files are organized by user and context for isolation and auditing:
s3://bucket/attachments/{userId}/{context}/{nanoid}.{ext}
Examples:
- s3://bucket/attachments/user_abc123/post/vY3kL9mN4pQ2.png
- s3://bucket/attachments/user_xyz789/general/pQ2vY3kL9mN4.jpgAdvanced Usage
Custom File Upload Handler
Build your own upload handler using the underlying utilities:
import { trpc } from "~/lib/trpc/client";
import { uploadToS3WithPresignedUrl, validateFileSize } from "sharelib/storage";
export async function customUploadFile(file: File): Promise<string> {
// Step 1: Client-side validation
const MAX_SIZE = 50 * 1024 * 1024; // 50MB custom limit
validateFileSize(file.size, MAX_SIZE);
// Step 2: Request presigned URL
const result = await trpc.attachments.requestUploadUrl.mutate({
fileName: file.name,
mimeType: file.type,
sizeBytes: file.size,
context: "general",
});
// Step 3: Upload to S3
const response = await uploadToS3WithPresignedUrl(
result.uploadUrl,
file,
file.type
);
if (!response.ok) {
throw new Error(`Upload failed: ${response.status}`);
}
// Step 4: Return public URL
return result.publicUrl;
}Progress Tracking
Track upload progress with XMLHttpRequest:
import { trpc } from "~/lib/trpc/client";
export async function uploadWithProgress(
file: File,
onProgress: (percent: number) => void
): Promise<string> {
// Get presigned URL
const { uploadUrl, publicUrl } = await trpc.attachments.requestUploadUrl.mutate({
fileName: file.name,
mimeType: file.type,
sizeBytes: file.size,
});
// Upload with progress tracking
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener("progress", (e) => {
if (e.lengthComputable) {
const percent = (e.loaded / e.total) * 100;
onProgress(percent);
}
});
xhr.addEventListener("load", () => {
if (xhr.status === 200) {
resolve(publicUrl);
} else {
reject(new Error(`Upload failed: ${xhr.status}`));
}
});
xhr.addEventListener("error", () => reject(new Error("Upload failed")));
xhr.open("PUT", uploadUrl);
xhr.setRequestHeader("Content-Type", file.type);
xhr.send(file);
});
}Custom Storage Paths
Modify storage key generation in the server router:
// Default: attachments/{userId}/{context}/{nanoid}.{ext}
const storageKey = `attachments/${userId}/${context}/${generateNanoID()}${ext ? `.${ext}` : ""}`;
// Custom: projects/{projectId}/documents/{timestamp}-{filename}
const storageKey = `projects/${projectId}/documents/${Date.now()}-${input.fileName}`;Integration Examples
With RichEditor
See Rich Text Editor for detailed RichEditor integration with image upload support.
import { RichEditor } from "sharelib/richeditor";
import { useImageUpload } from "~/hooks/useImageUpload";
export function BlogPostEditor() {
const [content, setContent] = useState("");
const { handleImageUpload } = useImageUpload({ context: "post" });
return (
<RichEditor
content={content}
onChange={setContent}
onImageUpload={handleImageUpload}
placeholder="Write your post... Paste or drag images!"
/>
);
}With Form Submission
Handle attachments in form submissions:
import { useAttachmentUpload } from "~/hooks/useAttachmentUpload";
import { trpc } from "~/lib/trpc/client";
export function IssueForm() {
const [attachments, setAttachments] = useState<string[]>([]);
const { handleFileUpload, uploading } = useAttachmentUpload();
const createIssue = trpc.issues.create.useMutation();
const onSubmit = async (formData: { title: string; description: string }) => {
await createIssue.mutateAsync({
...formData,
attachmentUrls: attachments, // Include uploaded file URLs
});
};
const onFilesAdded = async (files: FileList) => {
const fileParts = Array.from(files).map(file => ({
type: "file" as const,
filename: file.name,
mimeType: file.type,
url: URL.createObjectURL(file),
}));
const uploaded = await handleFileUpload(fileParts);
setAttachments(prev => [...prev, ...uploaded.map(f => f.url)]);
};
return (
<form onSubmit={onSubmit}>
{/* Form fields */}
<input type="file" multiple onChange={(e) => e.target.files && onFilesAdded(e.target.files)} />
{uploading && <p>Uploading...</p>}
<button type="submit" disabled={uploading}>Submit</button>
</form>
);
}Troubleshooting
Upload fails with 403 Forbidden
Cause: S3 credentials are invalid or bucket policy is incorrect.
Solution:
- Verify AWS credentials in
.env - Check S3 bucket CORS configuration:
[
{
"AllowedHeaders": ["*"],
"AllowedMethods": ["PUT", "GET"],
"AllowedOrigins": ["*"],
"ExposeHeaders": ["ETag"]
}
]Upload fails with "File size exceeds limit"
Cause: File is larger than 10MB.
Solution:
- Compress the file
- Or increase
MAX_FILE_SIZEinsrc/server/routers/attachments.ts
Upload succeeds but public URL returns 404
Cause: File was uploaded but presigned URL expired.
Solution: Call attachments.confirmUpload to get a fresh presigned URL.
MIME type not allowed
Cause: File type is not in the allowed list.
Solution: Add MIME type to allowedMimeTypes in src/server/routers/attachments.ts.
Best Practices
- Always validate on server-side - Never trust client-side validation alone
- Use context for organization - Use
"post"for blog posts,"general"for other files - Set reasonable file size limits - Default 10MB is good for images; adjust based on use case
- Handle errors gracefully - Show user-friendly error messages
- Clean up blob URLs - Call
URL.revokeObjectURL()after upload to free memory - Use presigned URLs wisely - They expire; regenerate if needed for long-term storage
- Monitor S3 costs - Track uploads and storage usage in production
Related Documentation
- Rich Text Editor - RichEditor with image upload
- API - tRPC - tRPC router setup and usage
- Authentication - User authentication with Clerk
Next Steps
Set up S3 credentials
Add AWS credentials to your .env file and configure S3 bucket CORS.
Try the upload hooks
Use useImageUpload or useAttachmentUpload in your components.
Customize validation
Adjust file size limits and allowed MIME types based on your needs.
Implement progress tracking
Add upload progress indicators for better UX.
You're ready! The upload system is production-ready with security, validation, and scalability built-in.