General
Storage

Overview

Learn how to manage file uploads and storage with S3-compatible providers.

The Pro Next.js Drizzle starter kit includes a robust storage service pre-configured for Cloudflare R2, but compatible with any S3-compatible service (AWS S3, DigitalOcean Spaces, MinIO, Supabase Storage, etc.).

Features

The storage service provides:

  • Signed URLs for secure uploads and downloads
  • Path validation to prevent security issues
  • S3-compatible API supporting multiple providers
  • Direct client uploads to reduce server load
  • Type-safe storage operations
  • Automatic URL generation for accessing files

Supported Providers

  • Cloudflare R2 - Recommended, no egress fees
  • AWS S3 - Industry standard
  • DigitalOcean Spaces - Simple and affordable
  • MinIO - Self-hosted S3-compatible storage
  • Supabase Storage - Integrated with Supabase
  • Any S3-compatible service

How It Works

1. Server-Side: Generate Signed URLs

The storage service generates signed URLs on the server that allow clients to upload or download files directly:

trpc/routers/storage/index.ts
import { createTRPCRouter, protectedProcedure } from '@/trpc/init';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';

import { storageConfig } from '@/config/storage.config';
import { getSignedUploadUrl } from '@/lib/storage';

export const storageRouter = createTRPCRouter({
  signedUploadUrl: protectedProcedure
    .input(z.object({ path: z.string(), bucket: z.string() }))
    .mutation(async ({ input }) => {
      if (input.bucket === storageConfig.bucketNames.images) {
        const signedUrl = await getSignedUploadUrl(input.path, input.bucket);
        return { signedUrl };
      }
      throw new TRPCError({ code: 'FORBIDDEN' });
    })
});

2. Client-Side: Direct Upload

The client uses the signed URL to upload directly to the storage provider:

components/upload.tsx
'use client';

async function uploadFile(file: File) {
  // Get signed URL from server
  const { signedUrl } = await fetch('/api/upload', {
    method: 'POST',
    body: JSON.stringify({ path: 'avatars/user-1.png', bucket: 'images' })
  }).then((res) => res.json());

  // Upload directly to storage
  await fetch(signedUrl, {
    method: 'PUT',
    body: file,
    headers: { 'Content-Type': file.type }
  });
}

3. Access Files

Files are accessed through a file proxy route at /storage/[...path]:

hooks/use-storage.tsx
'use client';

import { useMemo } from 'react';

import { storageConfig } from '@/config/storage.config';

export function useStorage(
  image: string | undefined | null,
  fallback?: string
): string | undefined {
  return useMemo(() => {
    if (!image) {
      return fallback;
    }
    if (image.startsWith('http')) {
      return image;
    }
    return `/storage/${storageConfig.bucketNames.images}/${image}`;
  }, [image, fallback]);
}

The /storage/[...path] route automatically generates signed URLs for file access and redirects to the storage provider.

Security

  • Signed URLs: All uploads and downloads use time-limited signed URLs
  • Path Validation: All file paths are sanitized to prevent traversal attacks
  • CORS: Buckets are configured with CORS policies to allow uploads from your domain
  • Access Control: Files are not publicly accessible by default - access is controlled through signed URLs

Storage Functions

The storage functions in lib/storage provide:

  • getSignedUploadUrl(path, bucket) - Generate signed URLs for uploads
  • getSignedUrl(path, bucket, expiresIn) - Generate signed URLs for downloads
  • Automatic path validation to prevent security issues

Configuration

Storage is configured through environment variables:

.env
S3_ACCESS_KEY_ID="your-access-key"
S3_SECRET_ACCESS_KEY="your-secret-key"
S3_ENDPOINT="https://your-endpoint.com"
S3_REGION="us-east-1"
S3_BUCKET="your-bucket-name"

Bucket names are configured in config/storage.config.ts:

config/storage.config.ts
import { env } from '@/lib/env';

export const storageConfig = {
  bucketNames: {
    images: env.NEXT_PUBLIC_IMAGES_BUCKET_NAME ?? ''
  }
} satisfies StorageConfig;

Best Practices

  1. Use signed URLs - Never expose storage credentials to the client
  2. Validate paths - Always validate file paths to prevent security issues
  3. Set expiration times - Use appropriate expiration times for signed URLs
  4. Configure CORS - Set up CORS policies correctly for your domain
  5. Use appropriate buckets - Organize files into logical buckets
  6. Monitor usage - Keep track of storage usage and costs