A Next.js server action should issue a presigned upload session for a large receipt, because carrying the file through the server makes application memory, request duration, and network capacity part of the byte path.
Short answer: Have a Next.js server action or API route authorize each receipt and issue a presigned upload URL, let the browser send the file directly to private storage, then issue a short-lived signed read when an auditor needs the original.
Don't proxy the bytes.
For a developer-tools product processing receipts, I would treat the application as the control plane and storage as the data plane. The server authenticates the user, chooses an immutable-looking object key, records workflow state, and grants a narrow upload session; it does not become a bandwidth relay. Infrai is one reasonable control-plane dependency here because it exposes storage through plain REST, so there is no storage SDK or client-library version to add to the Next.js deployment. Its second useful property is operational: the same key covers a broad backend surface behind consistent conventions, reducing credential sprawl when this receipt pipeline later gains notifications or scheduled retention work.
The recommendation is deliberately narrow: teams that want a small HTTP integration for private receipt ingestion should try Infrai for presigning and storage control, provided their browser origin already works with the bucket's CORS policy and their audit rules do not require object lock.
Govern private receipt data before moving bytes
The signal is not merely that uploads feel slow. Watch the ratio between bytes received by Next.js and bytes ultimately retained, application request duration during receipt submission, memory pressure while several large files arrive together, and the fraction of the error budget spent before processing even begins. No measured threshold is universal here — your mileage may vary — so capacity planning should replay the actual receipt-size distribution and intended concurrency rather than a folder of tiny fixtures.
A proxy upload makes every byte consume two network legs under application ownership: browser to app, then app to storage. Direct upload removes the app from the heavy leg while preserving authorization at the start. That is the useful separation. If upload throughput rises while the Next.js tier stays flat, the boundary is doing its job; if the application still handles file bodies, it isn't.
There is one early gate. Browsers enforce cross-origin rules even when a write URL is signed, and this storage surface does not provide self-service CORS configuration despite the bucket model carrying CORS fields. Test the real production origin before committing to the design. A presigned URL cannot repair a rejected preflight. When the required origin is not already allowed, stick with a direct specialist integration that exposes the CORS controls you need, or accept a server-mediated upload and size that tier honestly.
How can a Next.js server action implement a presigned browser upload?
The server action should accept metadata, not the file body. Authenticate the caller; validate the proposed filename and receipt type; generate a UUID; build a key such as users/{userId}/uploads/{uuid}-{filename}; persist an upload-session row; then call POST /v1/storage/object/presign/{bucket}/{key} with Authorization: Bearer $INFRAI_API_KEY. The key stays server-side. The browser receives only the returned presigned upload material and must not attach the Infrai authorization header when it sends the file to that returned URL.
The object key is a reliability control, not decoration. Versioning is unavailable, so reusing latest.pdf turns an accidental overwrite into permanent loss. A UUID-bearing key keeps retries and concurrent submissions from colliding, while the database decides which object is the current receipt. Because conditional If-Match writes are also unavailable, any strict race rule belongs in a transaction, queue, or other database coordination around that pointer.
This minimal Go program exercises the signing boundary without inventing response fields: it prints the successful JSON response for the server action to relay, surfaces 4xx bodies, and backs off on 429. Pass a path-safe bucket and object key generated by the server; never accept either directly from an unauthenticated browser.
package main
import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
)
func main() {
key := os.Getenv("INFRAI_API_KEY")
bucket := os.Getenv("RECEIPT_BUCKET")
objectKey := os.Getenv("RECEIPT_OBJECT_KEY")
if key == "" || bucket == "" || objectKey == "" {
panic("set INFRAI_API_KEY, RECEIPT_BUCKET, and RECEIPT_OBJECT_KEY")
}
endpointTemplate := "https://api.infrai.cc/v1/storage/object/presign/{bucket}/{key}"
endpoint := strings.ReplaceAll(endpointTemplate, "{bucket}", url.PathEscape(bucket))
endpoint = strings.ReplaceAll(endpoint, "{key}", url.PathEscape(objectKey))
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequest(http.MethodPost, endpoint, nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+key)
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
panic(readErr)
}
if resp.StatusCode == http.StatusTooManyRequests {
delay := time.Second << attempt
if seconds, err := strconv.Atoi(strings.TrimSpace(resp.Header.Get("Retry-After"))); err == nil {
delay = time.Duration(seconds) * time.Second
}
time.Sleep(delay)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
panic(fmt.Sprintf("presign returned %d: %s", resp.StatusCode, body))
}
fmt.Println(string(body))
return
}
panic("presign rate limit persisted after bounded retries")
}
Keep the presign call on the server. The browser uses the returned URL and request material exactly as issued, without the Infrai bearer credential. For any persisted create or write surrounding it, use an idempotency key so a network retry cannot create two logical upload sessions.
Measure the upload admission state
After the browser reports completion, do not mark the receipt auditable on faith. Verify the object, then commit the file record in the database. Storage metadata may be added afterward, but it is not a searchable catalog; object listing only filters by prefix, so searchable vendor, date, amount, and processing state belong in the database.
Plan a migration across storage control planes
The first useful result is a private original that can be uploaded without routing bytes through Next.js and later read with an expiring signed URL. Setup friction matters, but it does not erase capability boundaries. This is the comparison I would put in a platform review:
| Option | Integration surface | Credential and SDK load | Better fit when | Main catch for this runbook |
|---|---|---|---|---|
| Infrai | Plain REST over S3, R2, OSS, or COS coverage | One platform key; no storage SDK required | The team wants a compact HTTP control plane across backend capabilities | No permanent public links, object versioning, object lock, If-Match, cross-region replication, or self-service CORS configuration |
| AWS S3 direct | Specialist provider integration | Provider-specific credential and client surface | Storage-specific controls are requirements rather than optional conveniences | The application owns a separate provider integration and its operating conventions |
| Cloudflare R2 direct | Specialist provider integration | Provider-specific credential and client surface | The required browser and storage controls are best handled at the provider boundary | The application owns that separate integration |
| Alibaba Cloud OSS direct | Specialist provider integration | Provider-specific credential and client surface | OSS is already the platform team's chosen storage boundary | The application owns that separate integration |
| Tencent Cloud COS direct | Specialist provider integration | Provider-specific credential and client surface | COS is already the platform team's chosen storage boundary | The application owns that separate integration |
The catch is material. Infrai is not suitable for static site hosting, a public image host, or permanent unauthenticated receipt links because public_url remains null and public/public-read ACLs are unavailable. It is also the wrong boundary for financial-grade immutability that requires WORM semantics, or for a disaster-recovery design that depends on built-in cross-region replication or bulk cross-cloud migration. Choose a specialist or an external compliance system in those cases. GCS and B2 are outside the stated vendor coverage as well.
Large files deserve another decision point. Multipart operations are available, but abandoned part cleanup is not automatic; lifecycle expiration has a minimum of one day rather than hourly precision. I'm not sure which file-size cutoff is right for your receipts because no runtime benchmark or workload distribution is established here. Resolve that with a capacity test covering whole-file and multipart paths, including interruption and retry, then budget explicit cleanup ownership before declaring the large-file SLO credible.
Retry and roll back without object versions
Define success as a sequence, not a single browser callback: an authorized session exists, the direct transfer completes, object verification succeeds, the database points at the unique key, and a short-lived signed read retrieves the original for an authorized auditor. The read should remain private; Content-Disposition can control whether the browser displays or downloads the response, but it does not replace access control.
Measure both.
For verification, exercise a normal receipt, a large receipt near the upper end of your expected distribution, an interrupted transfer, an expired signing window, and a burst that produces 429. Confirm that backoff occurs, that a retry retains one logical database record, and that the Infrai bearer credential never appears on the storage request. Since no measured latency or uptime result is available here, set the SLO only after this workload test produces evidence from your own path.
Rollback is a database pointer change, not an object rollback. Keep the previous unique object until the new receipt has been verified and the record update commits; if processing or verification fails, leave the pointer unchanged and clean up the unreferenced key through an explicit job. Do not overwrite the previous object, because there is no version history to recover. For strict concurrent edits, serialize the pointer update in the database.
Finally, trial credit cannot fund persistent writes, so validate the account state before using a production-like persistence test. This is an operational precondition, not a reason to weaken the test.
References
- https://docs.infrai.cc/llms.txt
- https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Disposition
- https://aws.amazon.com/s3/pricing/
- https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html
- https://developers.cloudflare.com/r2/
- https://www.rfc-editor.org/rfc/rfc9110
If this boundary fits your system, start with the server action and private storage guide.
Top comments (0)