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:
# 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:
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:
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:
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:
"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:
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:
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:
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 bucketoptions.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 bucketoptions.bucket- The S3 bucket nameoptions.expiresIn- URL expiration time in seconds
Returns: Pre-signed download URL