ShipNext

Storage

S3-compatible object storage, browser multipart upload, and storage usage tracking.

ShipNext connects to any S3-compatible object storage through @aws-sdk/client-s3, including AWS S3, Cloudflare R2, and Supabase Storage. Storage code is concentrated in src/modules/storage/, and API routes live under app/api/storage/.

Typical capabilities:

  • Server-side storage.upload, storage.delete, and storage.getUrl
  • Browser multipart upload with presigned URLs
  • storage_files database records for user files and usage
  • Storage quota checks before upload when billing entitlements are configured

The current implementation keeps the S3 client and adapter logic in src/modules/storage/provider.ts instead of a separate src/integrations/storage/ directory.

Environment variables

Configure .env.local:

VariableRequiredDescription
S3_ACCESS_KEY_IDYesAccess key ID
S3_SECRET_ACCESS_KEYYesSecret access key
S3_BUCKET_NAMEYesBucket name
S3_ENDPOINTFor R2/S3-compatible providersAPI endpoint, for example R2 endpoint
S3_REGIONOptionalRegion, default auto
S3_PUBLIC_URLOptionalPublic object URL root used by server URL generation
NEXT_PUBLIC_STORAGE_PUBLIC_URLOptionalBrowser-visible public URL root
S3_ENDPOINT=https://<account_id>.r2.cloudflarestorage.com
S3_REGION=auto
S3_ACCESS_KEY_ID=your-access-key
S3_SECRET_ACCESS_KEY=your-secret-key
S3_BUCKET_NAME=your-bucket
S3_PUBLIC_URL=https://cdn.yourdomain.com

Public URL resolution priority:

  1. S3_PUBLIC_URL or NEXT_PUBLIC_STORAGE_PUBLIC_URL -> {publicUrl}/{objectKey}
  2. S3_ENDPOINT -> {endpoint}/{bucket}/{objectKey}
  3. AWS virtual-hosted style -> https://{bucket}.s3.{region}.amazonaws.com/{objectKey}

If credentials are missing, paths that call getS3Client() throw a storage configuration error at runtime.

Bucket CORS

Browser multipart uploads send PUT requests directly to object storage. Configure CORS on the bucket to allow at least:

  • Origins: your site domain, including http://localhost:3000 for local development
  • Methods: PUT, GET, HEAD
  • Headers: Content-Type and any provider-required upload headers

Without CORS, uploads fail in the browser even if the presigned URL is valid.

Public access

Files displayed in the app, such as avatars, must be reachable through S3_PUBLIC_URL or the provider's public/CDN domain. If the bucket is private, use a CDN or custom public domain that can read the objects.

Upload flow

Browser uploads use S3 Multipart Upload and presigned part URLs, even for small files. The default part size is 5 MB.

If a multipart upload fails, the client calls POST /api/storage/presigned-url/abort to clean up.

ConstantValueDescription
MULTIPART_PART_SIZE5 MBPart size
MAX_MULTIPART_CONCURRENCY4Concurrent part uploads
MAX_MULTIPART_PARTS10000Maximum number of parts

HTTP API

All routes require a logged-in user. Unauthenticated requests return 401.

POST /api/storage/presigned-url

Starts a multipart upload and returns presigned URLs.

Request body:

FieldRequiredDescription
fileNameYesOriginal filename
fileSizeYesFile size in bytes
expiresYesExpiration in hours; -1 means never expires
contentTypeOptionalMIME type
directoryOptionalSubdirectory, default uploads
maxFileSizeOptionalServer-side single-file limit

Success response:

{
  "objectKey": "userId/uploads/uuid-file.png",
  "uploadId": "...",
  "parts": [{ "partNumber": 1, "url": "https://..." }]
}

Error codes:

codeHTTPDescription
INVALID_EXPIRES400Invalid expires value
FILE_TOO_LARGE400Exceeds maxFileSize
QUOTA_EXCEEDED403Exceeds storage quota
AUTH_FAILED401User is not logged in

Object keys are placed under the user's directory by default.

POST /api/storage/presigned-url/complete

Completes multipart upload and writes a storage_files record.

{
  "objectKey": "...",
  "uploadId": "...",
  "parts": [{ "partNumber": 1, "etag": "\"...\"" }],
  "expires": -1
}

Success response:

{ "success": true, "key": "...", "url": "..." }

The server reads the final size with HeadObject before inserting the file record.

POST /api/storage/presigned-url/abort

Cancels multipart upload:

{ "objectKey": "...", "uploadId": "..." }

GET /api/storage/usage

Returns current user usage:

{
  "used": 1048576,
  "quota": 104857600
}

quota: null means no API-level storage limit is configured.

Client upload helper

Use uploadFile in Client Components:

import { uploadFile, validateFilesClient } from "@/modules/storage/client";

const validation = validateFilesClient([file], {
  maxFiles: 1,
  maxFileSize: 5 * 1024 * 1024,
  accept: ["image/*"],
});

if (validation) {
  // validation.code / validation.message
}

const result = await uploadFile(file, {
  expires: -1,
  directory: "avatar",
  maxFileSize: 5 * 1024 * 1024,
  onProgress: (percent) => console.log(percent),
});

// result.key - S3 object key
// result.url - accessible URL

uploadFile calls the presigned-url, complete, and abort APIs and manages part upload concurrency.

S3Upload component

import { S3Upload } from "@/modules/storage/client";

<S3Upload
  expires={-1}
  directory="documents"
  accept={["image/*", ".pdf"]}
  maxFiles={5}
  maxFileSize={10 * 1024 * 1024}
  onFileSuccess={({ key, url }) => console.log(key, url)}
  onUploadSuccess={(keys) => console.log(keys)}
  onUploadError={({ code, message }) => console.error(code, message)}
/>

Server adapter

For server-side uploads or deletes:

import { storage } from "@/modules/storage";

const { key, url, size } = await storage.upload(
  "system/banner.png",
  buffer,
  { contentType: "image/png" },
);

await storage.delete(key);
const publicUrl = await storage.getUrl(key);

Resolve user avatar URLs

user.image may contain either a full URL or an S3 object key. Display it with:

import { resolveUserImageUrl } from "@/modules/storage";

const src = user.image ? resolveUserImageUrl(user.image) : null;

Built-in avatar flow

Profile settings upload avatars by:

  1. Validating image type and 5 MB limit.
  2. Uploading to the avatar directory with expires: -1.
  3. Submitting the returned key as avatarKey to POST /api/user/profile.

storage_files

The storage table is defined in both SQLite and PostgreSQL schemas:

FieldDescription
user_idOwner user
object_keyUnique S3 object key
file_nameDisplay filename
file_sizeSize in bytes
mime_typeOptional MIME type
expires_atExpiration timestamp; null means never expires
created_atRecord creation time

insertFileRecord runs in the complete step. getUserStorageUsage sums file_size for the user.

The repository also provides getExpiredFiles and deleteFileRecordByKey; you need to add your own cron/background task if you want automatic cleanup.

Storage quotas and billing

There are two related configurations:

MechanismLocationPurpose
plan.storageLimitsrc/modules/billing/config/plan.tsDashboard usage progress display
getStorageQuota(planId, priceId)Price entitlements with resourceType: StorageAPI-level upload quota check

The default plans include storageLimit, but may not include Storage entitlements in prices[].entitlements. In that case:

  • Dashboard can display a storage limit.
  • Upload API may return quota: null and not enforce a storage cap.

To enforce upload quota, add a Storage entitlement to the relevant price:

{
  entitlementType: EntitlementType.Quota,
  balance: 5 * 1024 * 1024 * 1024,
  resourceType: EntitlementResourceType.Storage,
}

Keep it aligned with storageLimit to avoid showing users one limit and enforcing another.

Client error codes

codeTypical cause
AUTH_FAILEDUser is not logged in or session expired
FILE_TOO_LARGEExceeds maxFileSize
INVALID_TYPEFile type or count does not match accept / maxFiles
INVALID_EXPIRESMissing or invalid expires
QUOTA_EXCEEDEDStorage quota exceeded
NETWORK_ERRORMultipart PUT or API request failed

Troubleshooting

S3 configuration missing

Check S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, and S3_BUCKET_NAME in the runtime environment.

Browser upload CORS error

Configure bucket CORS to allow your site origin and direct PUT uploads.

Upload succeeds but image returns 403

S3_PUBLIC_URL may not point to a publicly readable domain, or the bucket/object may not allow anonymous read.

Avatar still shows the old image

Confirm POST /api/user/profile receives avatarKey and display code uses resolveUserImageUrl.

Dashboard has a quota but upload is not limited

The upload API reads EntitlementResourceType.Storage, not plan.storageLimit. Add Storage entitlements or customize upload checks.

On this page