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

  1. Client requests a signed upload URL from your API
  2. Server generates a presigned URL with expiration
  3. Client uploads file directly to storage using the presigned URL
  4. 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

  1. Validate file types - Only allow specific file extensions
  2. Validate file size - Set maximum file size limits
  3. Isolate user files - Prefix paths with user/organization ID
  4. Use unique filenames - Prevent filename conflicts and collisions
  5. Set short expiration - Signed URLs should expire quickly (60 seconds for uploads)
  6. Validate on server - Never trust client-side validation alone