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, andstorage.getUrl - Browser multipart upload with presigned URLs
storage_filesdatabase 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:
| Variable | Required | Description |
|---|---|---|
S3_ACCESS_KEY_ID | Yes | Access key ID |
S3_SECRET_ACCESS_KEY | Yes | Secret access key |
S3_BUCKET_NAME | Yes | Bucket name |
S3_ENDPOINT | For R2/S3-compatible providers | API endpoint, for example R2 endpoint |
S3_REGION | Optional | Region, default auto |
S3_PUBLIC_URL | Optional | Public object URL root used by server URL generation |
NEXT_PUBLIC_STORAGE_PUBLIC_URL | Optional | Browser-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.comPublic URL resolution priority:
S3_PUBLIC_URLorNEXT_PUBLIC_STORAGE_PUBLIC_URL->{publicUrl}/{objectKey}S3_ENDPOINT->{endpoint}/{bucket}/{objectKey}- 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:3000for local development - Methods:
PUT,GET,HEAD - Headers:
Content-Typeand 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.
| Constant | Value | Description |
|---|---|---|
MULTIPART_PART_SIZE | 5 MB | Part size |
MAX_MULTIPART_CONCURRENCY | 4 | Concurrent part uploads |
MAX_MULTIPART_PARTS | 10000 | Maximum 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:
| Field | Required | Description |
|---|---|---|
fileName | Yes | Original filename |
fileSize | Yes | File size in bytes |
expires | Yes | Expiration in hours; -1 means never expires |
contentType | Optional | MIME type |
directory | Optional | Subdirectory, default uploads |
maxFileSize | Optional | Server-side single-file limit |
Success response:
{
"objectKey": "userId/uploads/uuid-file.png",
"uploadId": "...",
"parts": [{ "partNumber": 1, "url": "https://..." }]
}Error codes:
| code | HTTP | Description |
|---|---|---|
INVALID_EXPIRES | 400 | Invalid expires value |
FILE_TOO_LARGE | 400 | Exceeds maxFileSize |
QUOTA_EXCEEDED | 403 | Exceeds storage quota |
AUTH_FAILED | 401 | User 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 URLuploadFile 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:
- Validating image type and 5 MB limit.
- Uploading to the
avatardirectory withexpires: -1. - Submitting the returned key as
avatarKeytoPOST /api/user/profile.
storage_files
The storage table is defined in both SQLite and PostgreSQL schemas:
| Field | Description |
|---|---|
user_id | Owner user |
object_key | Unique S3 object key |
file_name | Display filename |
file_size | Size in bytes |
mime_type | Optional MIME type |
expires_at | Expiration timestamp; null means never expires |
created_at | Record 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:
| Mechanism | Location | Purpose |
|---|---|---|
plan.storageLimit | src/modules/billing/config/plan.ts | Dashboard usage progress display |
getStorageQuota(planId, priceId) | Price entitlements with resourceType: Storage | API-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: nulland 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
| code | Typical cause |
|---|---|
AUTH_FAILED | User is not logged in or session expired |
FILE_TOO_LARGE | Exceeds maxFileSize |
INVALID_TYPE | File type or count does not match accept / maxFiles |
INVALID_EXPIRES | Missing or invalid expires |
QUOTA_EXCEEDED | Storage quota exceeded |
NETWORK_ERROR | Multipart 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.