Documentation

Storage

Learn how to handle file uploads and storage in your Sushify Next.js application using S3-compatible storage

Sushify Next.js includes a flexible storage system that supports S3-compatible storage providers, including AWS S3, Cloudflare R2, DigitalOcean Spaces, and any other S3-compatible service.

Features

  • S3-Compatible: Works with any S3-compatible storage provider
  • Signed URLs: Secure file uploads and downloads
  • Type-Safe: Full TypeScript support
  • Multiple Providers: Support for AWS S3, Cloudflare R2, DigitalOcean Spaces, etc.
  • Pre-signed Upload URLs: Secure client-side uploads
  • Configurable Expiration: Control URL expiration times

Setup

1. Environment Variables

Configure your S3-compatible storage provider in .env.local:

.env.local
# S3 Configuration
S3_ENDPOINT="https://your-account-id.r2.cloudflarestorage.com"
S3_REGION="auto" # or your specific region
S3_ACCESS_KEY_ID="your-access-key-id"
S3_SECRET_ACCESS_KEY="your-secret-access-key"
NEXT_PUBLIC_AVATARS_BUCKET_NAME="your-bucket-name"

2. Provider Examples

AWS S3

S3_ENDPOINT="https://s3.us-east-1.amazonaws.com"
S3_REGION="us-east-1"
S3_ACCESS_KEY_ID="AKIA..."
S3_SECRET_ACCESS_KEY="..."
NEXT_PUBLIC_AVATARS_BUCKET_NAME="my-app-bucket"

Cloudflare R2

S3_ENDPOINT="https://your-account-id.r2.cloudflarestorage.com"
S3_REGION="auto"
S3_ACCESS_KEY_ID="..."
S3_SECRET_ACCESS_KEY="..."
NEXT_PUBLIC_AVATARS_BUCKET_NAME="my-app-bucket"

DigitalOcean Spaces

S3_ENDPOINT="https://nyc3.digitaloceanspaces.com"
S3_REGION="nyc3"
S3_ACCESS_KEY_ID="..."
S3_SECRET_ACCESS_KEY="..."
NEXT_PUBLIC_AVATARS_BUCKET_NAME="my-app-bucket"

MinIO (Self-hosted)

S3_ENDPOINT="http://localhost:9000"
S3_REGION="us-east-1"
S3_ACCESS_KEY_ID="minioadmin"
S3_SECRET_ACCESS_KEY="minioadmin"
NEXT_PUBLIC_AVATARS_BUCKET_NAME="my-app-bucket"

3. Configuration

The storage configuration is defined in config/index.ts. This file maps environment variables to bucket names that can be used throughout your application:

config/index.ts
export const config = {
  // ... other config
  storage: {
    // define the name of the buckets for the different types of files
    bucketNames: {
      avatars: process.env.NEXT_PUBLIC_AVATARS_BUCKET_NAME ?? "avatars",
    },
  },
};

You can extend this configuration to support multiple bucket types:

config/index.ts
export const config = {
  storage: {
    bucketNames: {
      avatars: process.env.NEXT_PUBLIC_AVATARS_BUCKET_NAME ?? "avatars",
      documents: process.env.NEXT_PUBLIC_DOCUMENTS_BUCKET_NAME ?? "documents",
      uploads: process.env.NEXT_PUBLIC_UPLOADS_BUCKET_NAME ?? "uploads",
    },
  },
};

Then use it in your API procedures:

import { config } from "@repo/config";
import { getSignedUploadUrl } from "@repo/storage";

// Use the configured bucket name
const uploadUrl = await getSignedUploadUrl(path, {
  bucket: config.storage.bucketNames.avatars,
});

Usage

Getting Signed Upload URLs

Generate a pre-signed URL for client-side file uploads:

packages/api/modules/uploads/procedures/create-upload-url.ts
import { config } from "@repo/config";
import { getSignedUploadUrl } from "@repo/storage";
import { protectedProcedure } from "../../../orpc/procedures";
import { z } from "zod";

export const createUploadUrl = protectedProcedure
  .input(
    z.object({
      filename: z.string(),
    })
  )
  .route({
    method: "POST",
    path: "/uploads/upload-url",
    tags: ["Uploads"],
    summary: "Create upload URL",
    description: "Create a signed upload URL for file uploads",
  })
  .handler(async ({ input, context: { user } }) => {
    // Generate a unique file path
    const path = `uploads/${user.id}/${Date.now()}-${input.filename}`;

    // Get a signed upload URL
    const uploadUrl = await getSignedUploadUrl(path, {
      bucket: config.storage.bucketNames.avatars,
    });

    return {
      uploadUrl,
      path,
    };
  });

Client-Side Upload

Upload files directly to S3 from the browser:

app/components/FileUpload.tsx
"use client";

import { orpc } from "@shared/lib/orpc-query-utils";
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";

export function FileUpload() {
  const [file, setFile] = useState<File | null>(null);
  const [uploadedPath, setUploadedPath] = useState("");

  const createUploadUrlMutation = useMutation(
    orpc.uploads.createUploadUrl.mutationOptions(),
  );

  const handleUpload = async () => {
    if (!file) return;

    try {
      // 1. Get signed upload URL from your API
      const { uploadUrl, path } = await createUploadUrlMutation.mutateAsync({
        filename: file.name,
      });

      // 2. Upload file directly to S3
      await fetch(uploadUrl, {
        method: "PUT",
        body: file,
        headers: {
          "Content-Type": file.type,
        },
      });

      // 3. Store the path or update your database
      setUploadedPath(path);
      alert("File uploaded successfully!");
    } catch (error) {
      console.error("Upload failed:", error);
      alert("Upload failed");
    }
  };

  return (
    <div>
      <input
        type="file"
        onChange={(e) => setFile(e.target.files?.[0] || null)}
      />
      <button
        onClick={handleUpload}
        disabled={!file || createUploadUrlMutation.isPending}
      >
        {createUploadUrlMutation.isPending ? "Uploading..." : "Upload"}
      </button>
      {uploadedPath && <p>Uploaded to: {uploadedPath}</p>}
    </div>
  );
}

Getting Signed Download URLs

Generate a pre-signed URL for secure file downloads:

packages/api/modules/uploads/procedures/get-download-url.ts
import { config } from "@repo/config";
import { getSignedUrl } from "@repo/storage";
import { protectedProcedure } from "../../../orpc/procedures";
import { z } from "zod";

export const getDownloadUrl = protectedProcedure
  .input(
    z.object({
      path: z.string(),
    })
  )
  .route({
    method: "POST",
    path: "/uploads/download-url",
    tags: ["Uploads"],
    summary: "Get download URL",
    description: "Generate a signed download URL for a file",
  })
  .handler(async ({ input }) => {
    // Generate a signed download URL that expires in 1 hour
    const downloadUrl = await getSignedUrl(input.path, {
      bucket: config.storage.bucketNames.avatars,
      expiresIn: 3600, // 1 hour in seconds
    });

    return { downloadUrl };
  });

Client-Side Download

"use client";

import { orpc } from "@shared/lib/orpc-query-utils";
import { useMutation } from "@tanstack/react-query";

export function DownloadButton({ filePath }: { filePath: string }) {
  const getDownloadUrlMutation = useMutation(
    orpc.uploads.getDownloadUrl.mutationOptions(),
  );

  const handleDownload = async () => {
    try {
      const { downloadUrl } = await getDownloadUrlMutation.mutateAsync({
        path: filePath,
      });

      // Open the download URL
      window.open(downloadUrl, "_blank");
    } catch (error) {
      console.error("Failed to get download URL:", error);
    }
  };

  return (
    <button
      onClick={handleDownload}
      disabled={getDownloadUrlMutation.isPending}
    >
      {getDownloadUrlMutation.isPending ? "Loading..." : "Download File"}
    </button>
  );
}

Advanced Usage

Multi-Part Upload for Large Files

For files larger than 5GB, use multi-part uploads:

packages/api/modules/uploads/procedures/create-multipart-upload.ts
import { S3Client, CreateMultipartUploadCommand } from "@aws-sdk/client-s3";
import { config } from "@repo/config";
import { protectedProcedure } from "../../../orpc/procedures";
import { z } from "zod";

const s3Client = new S3Client({
  region: process.env.S3_REGION!,
  endpoint: process.env.S3_ENDPOINT!,
  credentials: {
    accessKeyId: process.env.S3_ACCESS_KEY_ID!,
    secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!,
  },
});

export const createMultipartUpload = protectedProcedure
  .input(
    z.object({
      filename: z.string(),
    })
  )
  .route({
    method: "POST",
    path: "/uploads/multipart-upload",
    tags: ["Uploads"],
    summary: "Create multipart upload",
    description: "Create a multipart upload for large files",
  })
  .handler(async ({ input, context: { user } }) => {
    const path = `uploads/${user.id}/${Date.now()}-${input.filename}`;

    const command = new CreateMultipartUploadCommand({
      Bucket: config.storage.bucketNames.avatars,
      Key: path,
    });

    const response = await s3Client.send(command);

    return {
      uploadId: response.UploadId,
      path,
    };
  });

Image Optimization

Using Next.js Image with S3

Configure Next.js to optimize images from S3:

next.config.ts
const nextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: "https",
        hostname: "your-bucket.s3.amazonaws.com",
      },
      {
        protocol: "https",
        hostname: "*.r2.cloudflarestorage.com",
      },
    ],
  },
};
import Image from "next/image";
import { config } from "@repo/config";
import { getSignedUrl } from "@repo/storage";
import { db } from "@repo/database";

export async function UserAvatar({ userId }: { userId: string }) {
  // Get user's avatar path from database
  const user = await db.user.findUnique({
    where: { id: userId },
    select: { image: true },
  });

  if (!user?.image) {
    return <div>No avatar</div>;
  }

  // Generate signed URL
  const imageUrl = await getSignedUrl(user.image, {
    bucket: config.storage.bucketNames.avatars,
    expiresIn: 3600,
  });

  return (
    <Image
      src={imageUrl}
      alt="User avatar"
      width={200}
      height={200}
      className="rounded-full"
    />
  );
}

API Reference

getSignedUploadUrl()

function getSignedUploadUrl(
  path: string,
  options: {
    bucket: string;
  }
): Promise<string>;

Parameters:

  • path - The file path in the bucket
  • options.bucket - The S3 bucket name

Returns: Pre-signed upload URL (valid for 60 seconds)

getSignedUrl()

function getSignedUrl(
  path: string,
  options: {
    bucket: string;
    expiresIn: number;
  }
): Promise<string>;

Parameters:

  • path - The file path in the bucket
  • options.bucket - The S3 bucket name
  • options.expiresIn - URL expiration time in seconds

Returns: Pre-signed download URL