storage

star 3

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

mattwoodco By mattwoodco schedule Updated 2/23/2026

name: storage description: Unified file storage — S3-compatible (RustFS/MinIO) for local dev, Vercel Blob for production. Simple upload/download/delete/list API with presigned URLs. author: Claude version: 2.0.0 created: 2025-01-11 updated: 2026-02-13 dependencies: [docker, env-config]

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

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

Environment Variables

Add to .env.local:

# 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:

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

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

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

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,
        hasMore: response.IsTruncated ?? false,
      };
    } catch (error) {
      throw new StorageError("UPLOAD_FAILED", "Failed to list files", error);
    }
  }

  async upload(key: string, data: Buffer, options?: UploadOptions): Promise<StorageFile> {
    validateFileSize(data.length);
    const contentType = options?.contentType ?? getContentTypeFromFilename(key);

    if (data.length > MULTIPART_THRESHOLD) {
      return this.uploadMultipart(key, data, contentType, options);
    }
    return this.uploadSimple(key, data, contentType, options);
  }

  private async uploadSimple(
    key: string,
    data: Buffer,
    contentType: string,
    options?: UploadOptions
  ): Promise<StorageFile> {
    try {
      await this.client.send(
        new PutObjectCommand({
          Bucket: this.bucket,
          Key: key,
          Body: data,
          ContentType: contentType,
          Metadata: options?.metadata,
        })
      );
      return {
        key,
        name: key.split("/").pop() ?? key,
        size: data.length,
        contentType,
        url: `${this.endpoint}/${this.bucket}/${key}`,
        lastModified: new Date(),
      };
    } catch (error) {
      throw StorageError.uploadFailed(`Failed to upload ${key}`, error);
    }
  }

  private async uploadMultipart(
    key: string,
    data: Buffer,
    contentType: string,
    options?: UploadOptions
  ): Promise<StorageFile> {
    const createCmd = new CreateMultipartUploadCommand({
      Bucket: this.bucket,
      Key: key,
      ContentType: contentType,
      Metadata: options?.metadata,
    });
    const { UploadId } = await this.client.send(createCmd);
    if (!UploadId) throw StorageError.uploadFailed("No upload ID returned");

    try {
      const totalParts = Math.ceil(data.length / PART_SIZE);
      const parts: { PartNumber: number; ETag: string }[] = [];

      for (let i = 0; i < totalParts; i++) {
        const start = i * PART_SIZE;
        const end = Math.min(start + PART_SIZE, data.length);
        const response = await this.client.send(
          new UploadPartCommand({
            Bucket: this.bucket,
            Key: key,
            UploadId,
            PartNumber: i + 1,
            Body: data.subarray(start, end),
          })
        );
        if (!response.ETag) throw new Error("No ETag for part");
        parts.push({ PartNumber: i + 1, ETag: response.ETag });
      }

      await this.client.send(
        new CompleteMultipartUploadCommand({
          Bucket: this.bucket,
          Key: key,
          UploadId,
          MultipartUpload: { Parts: parts },
        })
      );

      return {
        key,
        name: key.split("/").pop() ?? key,
        size: data.length,
        contentType,
        url: `${this.endpoint}/${this.bucket}/${key}`,
        lastModified: new Date(),
      };
    } catch (error) {
      await this.client
        .send(new AbortMultipartUploadCommand({ Bucket: this.bucket, Key: key, UploadId }))
        .catch(() => {});
      throw StorageError.uploadFailed(`Multipart upload failed for ${key}`, error);
    }
  }

  async delete(key: string): Promise<void> {
    try {
      await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: key }));
    } catch (error) {
      throw new StorageError("DELETE_FAILED", `Failed to delete ${key}`, error);
    }
  }

  async download(key: string): Promise<Buffer> {
    try {
      const response = await this.client.send(
        new GetObjectCommand({ Bucket: this.bucket, Key: key })
      );
      if (!response.Body) throw StorageError.fileNotFound(key);
      const bytes = await response.Body.transformToByteArray();
      return Buffer.from(bytes);
    } catch (error) {
      if (error instanceof StorageError) throw error;
      throw new StorageError("DOWNLOAD_FAILED", `Failed to download ${key}`, error);
    }
  }

  async getUrl(key: string, expiresInSeconds = 3600): Promise<string> {
    const command = new GetObjectCommand({ Bucket: this.bucket, Key: key });
    return getSignedUrl(this.client, command, { expiresIn: expiresInSeconds });
  }
}

Step 4: Create src/lib/storage/vercel-blob-provider.ts

import { put, del, list } from "@vercel/blob";
import type { VercelBlobConfig } from "./config";
import { getContentTypeFromFilename, validateFileSize } from "./config";
import type {
  StorageProvider,
  StorageFile,
  UploadOptions,
  ListOptions,
  ListResult,
} from "./types";
import { StorageError } from "./types";

export class VercelBlobProvider implements StorageProvider {
  readonly name = "vercel-blob";
  private token: string;

  constructor(config: VercelBlobConfig) {
    this.token = config.token;
  }

  async list(options?: ListOptions): Promise<ListResult> {
    try {
      const response = await list({
        token: this.token,
        prefix: options?.prefix,
        limit: options?.limit ?? 100,
        cursor: options?.cursor,
      });
      const files: StorageFile[] = response.blobs.map((blob) => ({
        key: blob.pathname,
        name: blob.pathname.split("/").pop() ?? blob.pathname,
        size: blob.size,
        contentType: getContentTypeFromFilename(blob.pathname),
        url: blob.url,
        lastModified: new Date(blob.uploadedAt),
      }));
      return {
        files,
        cursor: response.cursor,
        hasMore: response.hasMore,
      };
    } catch (error) {
      throw new StorageError("UPLOAD_FAILED", "Failed to list files", error);
    }
  }

  async upload(key: string, data: Buffer, options?: UploadOptions): Promise<StorageFile> {
    validateFileSize(data.length);
    const contentType = options?.contentType ?? getContentTypeFromFilename(key);

    try {
      const result = await put(key, data, {
        token: this.token,
        access: "public",
        contentType,
        multipart: data.length > 5 * 1024 * 1024,
      });
      return {
        key: result.pathname,
        name: result.pathname.split("/").pop() ?? result.pathname,
        size: data.length,
        contentType: result.contentType,
        url: result.url,
        lastModified: new Date(),
      };
    } catch (error) {
      throw StorageError.uploadFailed(`Failed to upload ${key}`, error);
    }
  }

  async delete(key: string): Promise<void> {
    try {
      const response = await list({ token: this.token, prefix: key, limit: 1 });
      const blob = response.blobs.find((b) => b.pathname === key);
      if (!blob) return;
      await del(blob.url, { token: this.token });
    } catch (error) {
      throw new StorageError("DELETE_FAILED", `Failed to delete ${key}`, error);
    }
  }

  async download(key: string): Promise<Buffer> {
    try {
      const response = await list({ token: this.token, prefix: key, limit: 1 });
      const blob = response.blobs.find((b) => b.pathname === key);
      if (!blob) throw StorageError.fileNotFound(key);
      const fetchResponse = await fetch(blob.downloadUrl);
      if (!fetchResponse.ok) throw new Error(`HTTP ${fetchResponse.status}`);
      return Buffer.from(await fetchResponse.arrayBuffer());
    } catch (error) {
      if (error instanceof StorageError) throw error;
      throw new StorageError("DOWNLOAD_FAILED", `Failed to download ${key}`, error);
    }
  }

  async getUrl(key: string, _expiresInSeconds = 3600): Promise<string> {
    const response = await list({ token: this.token, prefix: key, limit: 1 });
    const blob = response.blobs.find((b) => b.pathname === key);
    if (!blob) throw StorageError.fileNotFound(key);
    return blob.url;
  }
}

Step 5: Create src/lib/storage/storage-provider.ts

import type { StorageProvider } from "./types";
import { getStorageConfig } from "./config";
import { S3Provider } from "./s3-provider";
import { VercelBlobProvider } from "./vercel-blob-provider";

let instance: StorageProvider | null = null;

export function getStorageProvider(): StorageProvider {
  if (instance) return instance;
  const config = getStorageConfig();
  switch (config.provider) {
    case "s3":
      instance = new S3Provider(config.config);
      break;
    case "vercel-blob":
      instance = new VercelBlobProvider(config.config);
      break;
  }
  return instance;
}

export function resetStorageProvider(): void {
  instance = null;
}

export type { StorageProvider, StorageFile, UploadOptions, ListOptions, ListResult } from "./types";
export { StorageError } from "./types";
export { MAX_FILE_SIZE, getContentTypeFromFilename } from "./config";

Step 6: Create src/app/api/storage/route.ts

import { type NextRequest, NextResponse } from "next/server";
import { getStorageProvider, type ListResult, StorageError } from "@/lib/storage/storage-provider";

export async function GET(
  request: NextRequest
): Promise<NextResponse<ListResult | { error: string }>> {
  try {
    const searchParams = request.nextUrl.searchParams;
    const prefix = searchParams.get("prefix") ?? undefined;
    const limitParam = searchParams.get("limit");
    const cursor = searchParams.get("cursor") ?? undefined;
    const limit = limitParam ? parseInt(limitParam, 10) : undefined;

    if (limit !== undefined && (Number.isNaN(limit) || limit < 1)) {
      return NextResponse.json({ error: "Invalid limit parameter" }, { status: 400 });
    }

    const storage = getStorageProvider();
    const result = await storage.list({ prefix, limit, cursor });
    return NextResponse.json(result);
  } catch (error) {
    if (error instanceof StorageError) {
      return NextResponse.json({ error: error.message }, { status: 500 });
    }
    return NextResponse.json({ error: "An unexpected error occurred" }, { status: 500 });
  }
}

Step 7: Create src/app/api/storage/upload/route.ts

import { type NextRequest, NextResponse } from "next/server";
import { getStorageProvider, type StorageFile, StorageError } from "@/lib/storage/storage-provider";

function generateFileKey(filename: string): string {
  const timestamp = Date.now();
  const random = Math.random().toString(36).substring(2, 8);
  const sanitized = filename.replace(/[^a-zA-Z0-9.-]/g, "_");
  return `uploads/${timestamp}-${random}-${sanitized}`;
}

export async function POST(
  request: NextRequest
): Promise<NextResponse<{ file: StorageFile } | { error: string }>> {
  try {
    const formData = await request.formData();
    const file = formData.get("file");

    if (!file || !(file instanceof File)) {
      return NextResponse.json({ error: "No file provided" }, { status: 400 });
    }

    const storage = getStorageProvider();
    const buffer = Buffer.from(await file.arrayBuffer());
    const key = generateFileKey(file.name);
    const contentType = file.type || "application/octet-stream";

    const result = await storage.upload(key, buffer, { contentType });
    return NextResponse.json({ file: result });
  } catch (error) {
    if (error instanceof StorageError) {
      const status = error.code === "FILE_TOO_LARGE" ? 413 : 500;
      return NextResponse.json({ error: error.message }, { status });
    }
    return NextResponse.json({ error: "An unexpected error occurred" }, { status: 500 });
  }
}

Step 8: Create src/app/api/storage/download/[key]/route.ts

import { type NextRequest, NextResponse } from "next/server";
import { getStorageProvider, StorageError } from "@/lib/storage/storage-provider";

type RouteParams = { params: Promise<{ key: string }> };

export async function GET(
  request: NextRequest,
  { params }: RouteParams
): Promise<NextResponse> {
  try {
    const { key } = await params;
    if (!key) {
      return NextResponse.json({ error: "File key is required" }, { status: 400 });
    }

    const decodedKey = decodeURIComponent(key);
    const storage = getStorageProvider();
    const buffer = await storage.download(decodedKey);

    const filename = decodedKey.split("/").pop() ?? decodedKey;
    const listResult = await storage.list({ prefix: decodedKey, limit: 1 });
    const meta = listResult.files.find((f) => f.key === decodedKey);
    const contentType = meta?.contentType ?? "application/octet-stream";

    const disposition = request.nextUrl.searchParams.get("download") === "true"
      ? "attachment"
      : "inline";

    return new NextResponse(new Uint8Array(buffer), {
      status: 200,
      headers: {
        "Content-Type": contentType,
        "Content-Disposition": `${disposition}; filename="${encodeURIComponent(filename)}"`,
        "Content-Length": buffer.length.toString(),
        "Cache-Control": "private, max-age=3600",
      },
    });
  } catch (error) {
    if (error instanceof StorageError && error.code === "FILE_NOT_FOUND") {
      return NextResponse.json({ error: "File not found" }, { status: 404 });
    }
    return NextResponse.json({ error: "An unexpected error occurred" }, { status: 500 });
  }
}

Step 9: Create src/app/api/storage/delete/route.ts

import { type NextRequest, NextResponse } from "next/server";
import { getStorageProvider, StorageError } from "@/lib/storage/storage-provider";

type DeleteBody = { key: string } | { keys: string[] };

function isValidBody(body: unknown): body is DeleteBody {
  if (typeof body !== "object" || body === null) return false;
  const obj = body as Record<string, unknown>;
  if ("key" in obj) return typeof obj.key === "string" && obj.key.trim() !== "";
  if ("keys" in obj) {
    return (
      Array.isArray(obj.keys) &&
      obj.keys.length > 0 &&
      obj.keys.every((k) => typeof k === "string" && k.trim() !== "")
    );
  }
  return false;
}

export async function POST(
  request: NextRequest
): Promise<NextResponse<{ success: true } | { error: string }>> {
  try {
    const body: unknown = await request.json();
    if (!isValidBody(body)) {
      return NextResponse.json(
        { error: 'Provide { key: string } or { keys: string[] }' },
        { status: 400 }
      );
    }

    const storage = getStorageProvider();

    if ("key" in body) {
      await storage.delete(body.key);
    } else {
      if (body.keys.length > 100) {
        return NextResponse.json({ error: "Batch limited to 100 files" }, { status: 400 });
      }
      await Promise.all(body.keys.map((k) => storage.delete(k)));
    }

    return NextResponse.json({ success: true });
  } catch (error) {
    if (error instanceof StorageError) {
      return NextResponse.json({ error: error.message }, { status: 500 });
    }
    return NextResponse.json({ error: "An unexpected error occurred" }, { status: 500 });
  }
}

Usage

import { getStorageProvider } from "@/lib/storage/storage-provider";

const storage = getStorageProvider();

// Upload
const file = await storage.upload("images/photo.jpg", buffer, {
  contentType: "image/jpeg",
});

// List
const { files } = await storage.list({ prefix: "images/" });

// Download
const data = await storage.download("images/photo.jpg");

// Delete
await storage.delete("images/photo.jpg");

// Get presigned URL (S3) or public URL (Vercel Blob)
const url = await storage.getUrl("images/photo.jpg", 3600);

API Endpoints

Method Path Description
GET /api/storage?prefix=&limit=&cursor= List files
POST /api/storage/upload Upload file (FormData with file field)
GET /api/storage/download/[key]?download=true Download file
POST /api/storage/delete Delete file(s) — { key } or { keys }
Install via CLI
npx skills add https://github.com/mattwoodco/skills --skill storage
Repository Details
star Stars 3
call_split Forks 0
navigation Branch main
article Path SKILL.md
More from Creator