Claude
Skills
Sign in
Back

storage

Included with Lifetime
$97 forever

Unified file storage — S3-compatible (RustFS/MinIO) for local dev, Vercel Blob for production. Simple upload/download/delete/list API with presigned URLs.

Backend & APIs

What this skill does


# Storage Skill

Server-side file storage abstraction with automatic provider detection. Uses S3-compatible storage (RustFS) locally and Vercel Blob in production.

## Prerequisites

- Next.js app with `src/` directory and App Router
- Docker setup (for local RustFS — see docker-compose section)

## Installation

```bash
bun add @aws-sdk/client-s3 @aws-sdk/s3-request-presigner @vercel/blob
```

## Environment Variables

Add to `.env.local`:

```env
# Local Development — S3-compatible storage (RustFS)
S3_ENDPOINT=http://localhost:9000
S3_ACCESS_KEY=rustfsadmin
S3_SECRET_KEY=rustfsadmin
S3_BUCKET=uploads
S3_REGION=us-east-1
```

For production, set `BLOB_READ_WRITE_TOKEN` in your Vercel project settings. When present, the provider auto-switches to Vercel Blob.

## Docker Setup

The `docker` skill (dependency) provides the RustFS service in `docker-compose.yml`. After `docker compose up`, create the uploads bucket:

```bash
bunx @rustfs/mc alias set local http://localhost:9000 rustfsadmin rustfsadmin
bunx @rustfs/mc mb local/uploads
```

## What Gets Created

```
src/
├── lib/
│   └── storage/
│       ├── types.ts              # StorageProvider interface, StorageFile, StorageError
│       ├── config.ts             # Environment config, constants, MIME types
│       ├── s3-provider.ts        # S3-compatible provider (RustFS, MinIO, AWS)
│       ├── vercel-blob-provider.ts  # Vercel Blob provider
│       └── storage-provider.ts   # Factory — getStorageProvider()
└── app/
    └── api/
        └── storage/
            ├── route.ts          # GET  /api/storage — list files
            ├── upload/
            │   └── route.ts      # POST /api/storage/upload — upload file
            ├── download/
            │   └── [key]/
            │       └── route.ts  # GET  /api/storage/download/[key] — download file
            └── delete/
                └── route.ts      # POST /api/storage/delete — delete file(s)
```

## Setup Steps

### Step 1: Create `src/lib/storage/types.ts`

```typescript
export interface StorageFile {
  key: string;
  name: string;
  size: number;
  contentType: string;
  url: string;
  lastModified: Date;
}

export interface UploadOptions {
  contentType?: string;
  metadata?: Record<string, string>;
}

export interface ListOptions {
  prefix?: string;
  limit?: number;
  cursor?: string;
}

export interface ListResult {
  files: StorageFile[];
  cursor?: string;
  hasMore: boolean;
}

export interface StorageProvider {
  readonly name: string;
  list(options?: ListOptions): Promise<ListResult>;
  upload(key: string, data: Buffer, options?: UploadOptions): Promise<StorageFile>;
  delete(key: string): Promise<void>;
  download(key: string): Promise<Buffer>;
  getUrl(key: string, expiresInSeconds?: number): Promise<string>;
}

export type StorageErrorCode =
  | "FILE_NOT_FOUND"
  | "FILE_TOO_LARGE"
  | "UPLOAD_FAILED"
  | "DOWNLOAD_FAILED"
  | "DELETE_FAILED"
  | "CONFIGURATION_ERROR";

export class StorageError extends Error {
  constructor(
    public readonly code: StorageErrorCode,
    message: string,
    public readonly cause?: unknown
  ) {
    super(message);
    this.name = "StorageError";
  }

  static fileNotFound(key: string): StorageError {
    return new StorageError("FILE_NOT_FOUND", `File not found: ${key}`);
  }

  static fileTooLarge(size: number, maxSize: number): StorageError {
    return new StorageError(
      "FILE_TOO_LARGE",
      `File size ${size} exceeds maximum ${maxSize}`
    );
  }

  static uploadFailed(message: string, cause?: unknown): StorageError {
    return new StorageError("UPLOAD_FAILED", message, cause);
  }

  static configurationError(message: string): StorageError {
    return new StorageError("CONFIGURATION_ERROR", message);
  }
}
```

### Step 2: Create `src/lib/storage/config.ts`

```typescript
import { StorageError } from "./types";

export interface S3Config {
  endpoint: string;
  accessKey: string;
  secretKey: string;
  bucket: string;
  region: string;
  forcePathStyle: boolean;
}

export interface VercelBlobConfig {
  token: string;
}

export type StorageConfig =
  | { provider: "s3"; config: S3Config }
  | { provider: "vercel-blob"; config: VercelBlobConfig };

export const MAX_FILE_SIZE = 5 * 1024 * 1024 * 1024; // 5GB

export function getStorageConfig(): StorageConfig {
  if (process.env.BLOB_READ_WRITE_TOKEN) {
    return {
      provider: "vercel-blob",
      config: { token: process.env.BLOB_READ_WRITE_TOKEN },
    };
  }

  const endpoint = process.env.S3_ENDPOINT;
  const accessKey = process.env.S3_ACCESS_KEY;
  const secretKey = process.env.S3_SECRET_KEY;
  const bucket = process.env.S3_BUCKET;

  if (!endpoint || !accessKey || !secretKey || !bucket) {
    throw StorageError.configurationError(
      "No storage configured. Set BLOB_READ_WRITE_TOKEN (Vercel Blob) or S3_ENDPOINT + S3_ACCESS_KEY + S3_SECRET_KEY + S3_BUCKET (S3)."
    );
  }

  return {
    provider: "s3",
    config: {
      endpoint,
      accessKey,
      secretKey,
      bucket,
      region: process.env.S3_REGION || "us-east-1",
      forcePathStyle: true,
    },
  };
}

export function getContentTypeFromFilename(filename: string): string {
  const ext = filename.split(".").pop()?.toLowerCase();
  const types: Record<string, string> = {
    jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png",
    gif: "image/gif", webp: "image/webp", svg: "image/svg+xml",
    mp3: "audio/mpeg", wav: "audio/wav", ogg: "audio/ogg", m4a: "audio/mp4",
    mp4: "video/mp4", webm: "video/webm", mov: "video/quicktime",
    pdf: "application/pdf", txt: "text/plain", csv: "text/csv",
    json: "application/json", zip: "application/zip", gz: "application/gzip",
  };
  return types[ext || ""] || "application/octet-stream";
}

export function validateFileSize(size: number): void {
  if (size > MAX_FILE_SIZE) {
    throw StorageError.fileTooLarge(size, MAX_FILE_SIZE);
  }
}
```

### Step 3: Create `src/lib/storage/s3-provider.ts`

```typescript
import {
  S3Client,
  ListObjectsV2Command,
  GetObjectCommand,
  PutObjectCommand,
  DeleteObjectCommand,
  CreateMultipartUploadCommand,
  UploadPartCommand,
  CompleteMultipartUploadCommand,
  AbortMultipartUploadCommand,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import type { S3Config } from "./config";
import { getContentTypeFromFilename, validateFileSize } from "./config";
import type {
  StorageProvider,
  StorageFile,
  UploadOptions,
  ListOptions,
  ListResult,
} from "./types";
import { StorageError } from "./types";

const PART_SIZE = 5 * 1024 * 1024; // 5MB
const MULTIPART_THRESHOLD = 5 * 1024 * 1024; // 5MB

export class S3Provider implements StorageProvider {
  readonly name = "s3";
  private client: S3Client;
  private bucket: string;
  private endpoint: string;

  constructor(config: S3Config) {
    this.bucket = config.bucket;
    this.endpoint = config.endpoint;
    this.client = new S3Client({
      region: config.region,
      endpoint: config.endpoint,
      credentials: {
        accessKeyId: config.accessKey,
        secretAccessKey: config.secretKey,
      },
      forcePathStyle: config.forcePathStyle,
    });
  }

  async list(options?: ListOptions): Promise<ListResult> {
    try {
      const command = new ListObjectsV2Command({
        Bucket: this.bucket,
        Prefix: options?.prefix,
        MaxKeys: options?.limit ?? 100,
        ContinuationToken: options?.cursor,
      });
      const response = await this.client.send(command);
      const files: StorageFile[] = (response.Contents ?? []).map((item) => {
        const key = item.Key ?? "";
        return {
          key,
          name: key.split("/").pop() ?? key,
          size: item.Size ?? 0,
          contentType: getContentTypeFromFilename(key),
          url: `${this.endpoint}/${this.bucket}/${key}`,
          lastModified: item.LastModified ?? new Date(),
        };
      });
      return {
        files,
        cursor: response.NextContinuationToken,
 
Files: 1
Size: 23.4 KB
Complexity: 32/100
Category: Backend & APIs

Related in Backend & APIs