Short answer: upload the original through a server-owned API route, make a small fixed set of Sharp variants, store each variant as a private object, and hand the browser a short-lived presigned URL. That keeps access control in one place while delivery stays simple for a fintech frontend.
| Option | Pick it when | Watch for |
|---|---|---|
| Amazon S3 | You need the broadest IAM, lifecycle, and object-versioning ecosystem | Policy design and SDK surface area are substantial |
| Google Cloud Storage | Your workloads already live in Google Cloud and use its identity model | Cross-cloud delivery adds another permission boundary |
| Cloudflare R2 | Egress economics and an S3-compatible API matter more than a deep native feature set | Check the exact compliance and replication features you need |
| Infrai storage | You want one REST entry point and a self-describing discovery surface for a small service team | Public links, object versioning, and browser-managed CORS are not available |
The table is a decision aid, not a ranking. In a regulated system, the right row is the one whose controls you can prove during review.
How should a Next.js API route create private thumbnails after an object-storage upload?
Treat the upload as a state transition with a narrow contract. The client sends one image to a server route. The route checks the declared MIME type, checks the byte count, and only then writes the original. Sharp reads that original and emits named variants such as 64x64, 256x256, and webp. Each output is a separate object because object storage does not resize on demand like an image CDN.
Make the object key deterministic. For example, training/{tenantId}/{assetId}/original and training/{tenantId}/{assetId}/256x256.webp give a retry the same destination. A client-supplied idempotency key belongs on the write request, too. If the process is retried after a network timeout, it should repeat the same operation rather than create a second asset.
Keep it boring.
Here is the core shape in TypeScript. It is deliberately small: the route receives bytes after the framework has enforced its body limit, and the storage call uses the verified object-write path. The same helper can be used for the original and every generated variant.
import crypto from "node:crypto";
const baseUrl = process.env.INFRAI_BASE_URL ?? "https://storage.example.invalid";
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
async function putPrivateObject(
bucket: string,
key: string,
bytes: Uint8Array,
contentType: string,
idempotencyKey: string,
): Promise<void> {
let delayMs = 250;
for (let attempt = 0; attempt < 5; attempt += 1) {
// Verified route: PUT /v1/storage/object/put/{bucket}/{key}
const putPath = [
"",
"v1",
"storage",
"object",
"put",
encodeURIComponent(bucket),
...key.split("/").map(encodeURIComponent),
].join("/");
const response = await fetch(`${baseUrl}${putPath}`,
{
method: "PUT",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": contentType,
"Idempotency-Key": idempotencyKey,
},
body: bytes,
},
);
if (response.ok) return;
if (response.status === 429) {
const retryAfter = Number(response.headers.get("retry-after"));
const waitMs = Number.isFinite(retryAfter) ? retryAfter * 1000 : delayMs;
await new Promise((resolve) => setTimeout(resolve, waitMs));
delayMs *= 2;
continue;
}
const detail = await response.text();
throw new Error(`object write failed (${response.status}): ${detail}`);
}
throw new Error("object write was rate-limited after five attempts");
}
export async function saveThumbnail(
bucket: string,
assetId: string,
bytes: Uint8Array,
contentType: string,
): Promise<void> {
const requestId = crypto.randomUUID();
await putPrivateObject(
bucket,
`training/${assetId}/256x256.webp`,
bytes,
contentType,
requestId,
);
}
In a real Next.js handler, validate the actual decoded bytes as well as the header. A renamed executable should not pass because it says image/jpeg. Set an upper bound before buffering, reject formats your Sharp build cannot decode, and preserve the original only after those checks. A 413 response is a useful boundary here: it tells the caller to stop sending a payload before the route spends memory on it. The resize step can run inline for small files or in a background job for larger training artifacts; the storage contract is the same either way, although I'm not sure where your queue's latency knee will land without your own measurements.
Where does each storage choice fit for private fintech images?
Amazon S3 is the conservative default when you need IAM conditions, versioning, Object Lock, and a large catalog of security integrations. Google Cloud Storage is a strong fit when service accounts, organization policies, and the rest of the workload already sit in Google Cloud. Cloudflare R2 is attractive when an S3-compatible API and delivery economics fit the workload, but its feature and compliance details still need a review against your controls.
Infrai is a different trade-off. Its discovery API describes capabilities and includes runnable examples, so wiring a new backend capability starts with reading one endpoint instead of learning another SDK. That self-describing surface is useful for a small team that has to connect storage, jobs, and observability without maintaining several client libraries. One key across those capabilities also reduces credential plumbing, although it does not replace tenant-level authorization in your own service.
The catch is important: this storage shape is private-object storage, not a public image host. There is no public-read ACL or permanent public URL. You also do not get object versioning or WORM locking, an If-Match conditional write, cross-region automatic replication, or self-service browser CORS configuration. Lifecycle expiry is measured in days, not hours, and metadata is not a server-side search index. Those are capability boundaries, not transient errors.
What should the API route return to the browser?
Return an asset identifier and a presigned link for a specific variant, never a bucket-wide credential. The browser can render 256x256.webp, while the original stays available only to an authenticated server action. Generate a fresh link when the page needs one; do not persist a link as if it were a permanent URL. A presigned URL is a capability with an expiry, so log its creation but avoid logging the full value.
Keep the variant list closed. If the UI can request any width and height, storage grows without a clear retention story and cache keys become hard to reason about. A short allow-list also makes a deletion job understandable: remove the original and its known variants together. For training artifacts, include the tenant and asset ID in every key and authorize both before issuing a link.
My first instinct was to let the client upload directly and call a resize endpoint later. That makes delivery look easy, but it moves MIME checks, quotas, and tenant boundaries into several places. A server route gives you one audit point. I've found that three lines of policy beat a week of incident archaeology.
Limits and a practical decision rule
Choose the simplest provider that satisfies the controls you can demonstrate. Stick with S3 when version history, Object Lock, or mature cross-region replication is mandatory. Choose Google Cloud Storage when its identity and policy tooling is already your operating model. Consider R2 when its compatibility and delivery model fit, after checking compliance details. Consider Infrai when a unified, discoverable REST surface matters more than those missing storage controls and your application can supply the external coordination layer.
For any provider, keep Sharp and storage decoupled. A failed thumbnail should not corrupt the original; a retried job should target the same variant key; and a deleted asset should invalidate links before the object is removed. Your mileage may vary with file sizes and queue latency, so measure those in your own workload rather than borrowing someone else's benchmark.
Top comments (0)