General
Storage
Upload Files
Learn how to upload files to your storage provider.
Before uploading files, make sure you've set up your storage provider.
Overview
The starter kit uses presigned URLs for file uploads. This approach:
- Reduces server load (files upload directly to storage)
- Improves performance (no file passing through your server)
- Maintains security (time-limited, signed URLs)
Upload Flow
- Client requests a signed upload URL from your API
- Server generates a presigned URL with expiration
- Client uploads file directly to storage using the presigned URL
- Client stores the file path in your database
Creating an Upload Endpoint
Create a tRPC procedure or API route to generate signed upload URLs:
trpc/routers/storage/index.ts
import { signedUploadUrlSchema } from '@/schemas/upload-schemas';
import { createTRPCRouter, protectedProcedure } from '@/trpc/init';
import { TRPCError } from '@trpc/server';
import { storageConfig } from '@/config/storage.config';
import { getSignedUploadUrl } from '@/lib/storage';
export const storageRouter = createTRPCRouter({
signedUploadUrl: protectedProcedure
.input(signedUploadUrlSchema)
.mutation(async ({ input }) => {
if (input.bucket === storageConfig.bucketNames.images) {
const signedUrl = await getSignedUploadUrl(input.path, input.bucket);
return { signedUrl };
}
throw new TRPCError({ code: 'FORBIDDEN' });
})
});Path Validation
Always validate and sanitize file paths:
trpc/routers/storage.ts
.input(
z.object({
path: z.string().min(1).refine(
(path) => {
// Only allow specific file types
const allowedExtensions = [".jpg", ".jpeg", ".png", ".pdf"];
return allowedExtensions.some((ext) => path.endsWith(ext));
},
{ message: "Invalid file type" }
),
// Ensure path includes user ID for isolation
userId: z.string(),
})
)
.mutation(async ({ input }) => {
// Prefix with user ID to isolate files
const safePath = `${input.userId}/${input.path}`;
// ... generate signed URL
});Uploading from the Client
Using React Hook Form
components/file-upload.tsx
'use client';
import { useState } from 'react';
import { trpc } from '@/trpc/client';
import { useForm } from 'react-hook-form';
import { storageConfig } from '@/config/storage.config';
import { useSession } from '@/hooks/use-session';
export function FileUpload() {
const { data: session } = useSession();
const [uploading, setUploading] = useState(false);
const getSignedUploadUrl = trpc.storage.signedUploadUrl.useMutation();
const handleFileChange = async (
event: React.ChangeEvent<HTMLInputElement>
) => {
const file = event.target.files?.[0];
if (!file || !session) return;
setUploading(true);
try {
// 1. Get signed upload URL
const { signedUrl } = await getSignedUploadUrl.mutateAsync({
path: file.name,
bucket: storageConfig.bucketNames.images
});
// 2. Upload file directly to storage
const response = await fetch(signedUrl, {
method: 'PUT',
body: file,
headers: {
'Content-Type': file.type
}
});
if (!response.ok) {
throw new Error('Upload failed');
}
// 3. Store file path in database
// await saveFileToDatabase({ path: file.name, userId: session.user.id });
console.log('File uploaded successfully:', file.name);
} catch (error) {
console.error('Upload error:', error);
} finally {
setUploading(false);
}
};
return (
<div>
<input
type="file"
onChange={handleFileChange}
disabled={uploading}
accept="image/*"
/>
{uploading && <p>Uploading...</p>}
</div>
);
}Using React Dropzone
components/dropzone-upload.tsx
'use client';
import { useCallback, useState } from 'react';
import { trpc } from '@/trpc/client';
import { useDropzone } from 'react-dropzone';
import { v4 as uuid } from 'uuid';
import { storageConfig } from '@/config/storage.config';
import { useSession } from '@/hooks/use-session';
export function DropzoneUpload() {
const { data: session } = useSession();
const [uploading, setUploading] = useState(false);
const getSignedUploadUrl = trpc.storage.signedUploadUrl.useMutation();
const onDrop = useCallback(
async (acceptedFiles: File[]) => {
if (!acceptedFiles.length || !session) return;
setUploading(true);
try {
const file = acceptedFiles[0];
// Generate unique filename
const fileExtension = file.name.split('.').pop();
const uniquePath = `${session.user.id}/${uuid()}.${fileExtension}`;
// Get signed URL
const { signedUrl } = await getSignedUploadUrl.mutateAsync({
path: uniquePath,
bucket: storageConfig.bucketNames.images
});
// Upload to storage
const response = await fetch(signedUrl, {
method: 'PUT',
body: file,
headers: {
'Content-Type': file.type
}
});
if (!response.ok) {
throw new Error('Upload failed');
}
// Store in database
// await saveFileToDatabase({ path: uniquePath, userId: session.user.id });
console.log('File uploaded:', uniquePath);
} catch (error) {
console.error('Upload error:', error);
} finally {
setUploading(false);
}
},
[session, getSignedUploadUrl]
);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: {
'image/*': ['.jpg', '.jpeg', '.png'],
'application/pdf': ['.pdf']
},
multiple: false
});
return (
<div
{...getRootProps()}
className="border-2 border-dashed p-8 text-center cursor-pointer"
>
<input {...getInputProps()} />
{uploading ? (
<p>Uploading...</p>
) : isDragActive ? (
<p>Drop the file here...</p>
) : (
<p>Drag & drop a file here, or click to select</p>
)}
</div>
);
}Upload Progress
For large files, you can track upload progress:
components/upload-with-progress.tsx
'use client';
import { useState } from 'react';
import { trpc } from '@/trpc/client';
import { storageConfig } from '@/config/storage.config';
export function UploadWithProgress() {
const [progress, setProgress] = useState(0);
const getSignedUploadUrl = trpc.storage.signedUploadUrl.useMutation();
const uploadFile = async (file: File) => {
const { signedUrl } = await getSignedUploadUrl.mutateAsync({
path: file.name,
bucket: storageConfig.bucketNames.images
});
return new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percentComplete = (e.loaded / e.total) * 100;
setProgress(percentComplete);
}
});
xhr.addEventListener('load', () => {
if (xhr.status === 200) {
resolve();
} else {
reject(new Error('Upload failed'));
}
});
xhr.addEventListener('error', () => reject(new Error('Upload failed')));
xhr.open('PUT', signedUrl);
xhr.setRequestHeader('Content-Type', file.type);
xhr.send(file);
});
};
return (
<div>
<input
type="file"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) uploadFile(file);
}}
/>
{progress > 0 && (
<progress
value={progress}
max={100}
/>
)}
</div>
);
}File Size Limits
Add file size validation:
schemas/upload-schemas.ts
import { z } from 'zod';
export const signedUploadUrlSchema = z.object({
path: z.string().min(1),
bucket: z.string().min(1)
// Add file size validation on the client side before requesting URL
});Security Best Practices
- Validate file types - Only allow specific file extensions
- Validate file size - Set maximum file size limits
- Isolate user files - Prefix paths with user/organization ID
- Use unique filenames - Prevent filename conflicts and collisions
- Set short expiration - Signed URLs should expire quickly (60 seconds for uploads)
- Validate on server - Never trust client-side validation alone