For a customer-support system that must process receipts and retain the original file for audit, use a Next.js authorization boundary and a browser-to-private-object-storage transfer. The deciding constraint is large-file throughput: the application should authorize and record the upload, while the browser sends the bytes directly to storage instead of making the Next.js process a file proxy.
The important part is the contract around that transfer. A signed URL is temporary upload authority, not a public file address. The bucket stays private, the server chooses the object key, and the database records the business meaning of the file. Those boundaries matter more than the storage brand.
This is an architecture decision record for a practical receipt workflow. It assumes an S3-compatible data-plane shape, but compatibility alone does not prove that two services share the same CORS administration, retention controls, replication behavior, or billing model.
What should a Next.js server action do before a private browser upload?
The server action should authenticate the support agent or customer, validate the proposed filename and content type, create an upload intent, select a collision-resistant key, and return a short-lived signed write URL. It shouldn't accept the file bytes as part of that authorization request. The browser then uses the URL for the direct transfer.
Keep it private.
For example, a receipt intent might contain ticket_id, owner_id, the original filename, the expected content type, and a generated key such as tickets/8f2c/receipts/1b7e...-original.pdf. The database owns those fields and the upload state. Object storage owns the bytes. That division gives an auditor a searchable record without pretending that an object listing is a case-management database.
The state transition needs to be explicit:
-
created: the application has an intent but no storage object is confirmed. -
authorized: the server has issued upload authority for one exact key. -
uploaded: the browser reports a successful transfer and the server verifies the object according to the application's contract. -
recorded: the receipt is attached to the ticket and is available to the audit workflow.
Do not collapse these states into a boolean called uploaded. A browser can lose its connection after storage has accepted the last byte and before the confirmation request reaches Next.js. A retry can then overwrite the original key or create an orphan under a new key. An intent record gives reconciliation something durable to inspect. In a receipt workflow, that distinction is not cosmetic: an agent may see a successful upload in the browser, close the support ticket, and leave the audit process with no searchable attachment if confirmation was treated as an incidental callback. The reconciler must therefore compare the intent, the exact key, and the object's available integrity evidence before moving the ticket to recorded; it should not guess from a filename or from a second upload attempt.
The object key is an ownership decision, too. Do not let a browser choose an arbitrary path. A server-generated identifier prevents a filename collision and makes authorization easier to review: the server can check that the requested key belongs to the ticket before it signs a write or a later read. Keep the original filename as metadata in the application record, where it can be displayed safely.
The transfer contract and its failure boundaries
The critical path is short enough to test as a contract, even when the eventual file is large:
import os
from urllib.parse import urlparse
import requests
app_url = os.environ["APP_URL"].rstrip("/")
session_token = os.environ["TEST_SESSION"]
filename = "receipt-2026-08.pdf"
content_type = "application/pdf"
payload = b"test receipt bytes"
intent_response = requests.post(
f"{app_url}/api/receipt-upload",
headers={
"Authorization": f"Bearer {session_token}",
"Content-Type": "application/json",
},
json={
"ticket_id": "ticket-test-001",
"filename": filename,
"content_type": content_type,
},
timeout=15,
)
intent_response.raise_for_status()
intent = intent_response.json()
assert intent["state"] == "authorized"
assert intent["object_key"].startswith("tickets/")
assert urlparse(intent["upload_url"]).scheme == "https"
upload_response = requests.put(
intent["upload_url"],
headers={"Content-Type": content_type},
data=payload,
timeout=60,
)
upload_response.raise_for_status()
confirm_response = requests.post(
f"{app_url}/api/receipt-upload/confirm",
headers={
"Authorization": f"Bearer {session_token}",
"Content-Type": "application/json",
},
json={"intent_id": intent["intent_id"]},
timeout=15,
)
confirm_response.raise_for_status()
assert confirm_response.json()["state"] == "recorded"
The application endpoint names in this example are application-owned contracts, not claims about a storage provider's API. The storage adapter behind the server action can request the provider's presigned operation using that provider's documented SDK or HTTP contract. The browser must receive only the signed URL and the fields it needs for the transfer; it must never receive the server's storage credential. That's the boundary worth preserving in code review: the browser gets authority for one object operation, while the server retains the authority to decide who may create, confirm, read, or delete a receipt.
There are three useful failure boundaries. If authorization fails, no upload should be considered started. If storage accepts the object but confirmation fails, the intent is authorized or uploaded rather than silently marked recorded; a reconciliation job can inspect stale intents and apply a retention decision. If the browser is rejected at CORS preflight, the signed URL may be perfectly valid while the browser still cannot use it. Test that browser path separately from a server-side upload test.
A 413 at the application boundary means the design has accidentally routed bytes through the application or imposed an upload limit there. A 429 means the caller should back off and honor Retry-After, not retry in a tight loop. These are different controls with different owners. I keep them separate in dashboards because a throughput problem and an authorization-rate problem produce very different operational responses.
The large-file question is not answered by a signed URL alone. Measure the browser-to-storage path, decide when multipart transfer is required, define how interrupted parts are handled, and make the final confirmation idempotent. Your mileage may vary by region and network path; the test that resolves that uncertainty is a representative receipt corpus, not a tiny text file.
Choosing the boundary: direct transfer, proxying, or multipart
| Option | What the application owns | Good fit | Limitation to record in the ADR |
|---|---|---|---|
| Browser direct upload with a signed URL | Identity, key selection, intent state, confirmation | Large receipts where application throughput is the primary concern | Requires correct bucket CORS and a reconciliation policy for uncertain completion |
| Application proxy upload | Identity, bytes, transfer retries, and storage write | Policies that require inspection or transformation before storage | Application capacity and timeout behavior become part of the file-transfer path |
| Multipart direct upload | Identity, part authority, part tracking, completion | Files large enough that resumability matters | More state, more cleanup decisions, and more integration tests than one request |
The first option is the default for this scenario because it keeps the high-volume byte path away from the application tier. It is not a universal answer. If a compliance rule requires malware scanning before any object becomes durable, a proxy or a quarantine bucket may be the correct boundary. If the browser network is unreliable and receipts are genuinely large, multipart transfer may be worth its extra state even though a single signed write is easier to reason about.
The catch is that “S3-compatible” is not a complete operational specification. Confirm the method, signed headers, expiration behavior, CORS policy, multipart lifecycle, and read authorization for the exact service. A provider can match the object API vocabulary while exposing different controls around it. The adapter should isolate those differences so the Next.js action exposes a stable intent contract to the browser.
For private files, download is another authorization event. The ticket view should authenticate the requester and mint a short-lived signed read URL, or stream the file through an authorized service when policy requires that. Never turn the receipt bucket public to make the browser workflow easier. A permanent URL changes the threat model and makes revocation much harder.
Auditability requires more than retaining the original bytes
An audit record should answer who supplied the receipt, which ticket it belongs to, when the intent was created, when the object was confirmed, which object key was used, what content type was expected, and what disposition the support workflow assigned. The original filename is useful presentation data, not a durable identity.
Store a content digest or provider-returned integrity evidence only when the chosen storage contract and application verification process define what it means. Do not label an unverified client-provided hash as proof of the stored bytes. The audit trail should distinguish “client said this” from “the system checked this.”
Use Content-Disposition deliberately when serving a receipt. An attachment response can encourage download, while an inline response can permit browser rendering; either choice should be made by the application policy, not inherited accidentally from an uploaded filename. Sanitize the displayed name and keep authorization independent of it. The HTTP header syntax and disposition types are documented by MDN.
Retention is a policy decision, not a side effect of upload success. Define the legal hold behavior, deletion approval, stale-intent cleanup, and who can replace an object. If accidental replacement must be recoverable, choose a storage contract with verified versioning or keep immutable copies under new keys. A UUID-bearing key reduces collisions; it does not create version history.
Cost deserves a line in the record, but it should follow the workload rather than lead the recommendation. Request charges, transferred bytes, stored bytes, and incomplete multipart parts can all matter, and the exact accounting varies by service and region. The public storage pricing documentation is the place to validate those terms before deployment.
Rejected option and decision rule
The rejected option for this ADR is sending every receipt through a Next.js server action. It remains valid when the server must inspect or transform bytes before storage, or when a direct browser CORS policy cannot be arranged. It is a poor fit when large-file throughput is the primary axis and the application tier has no reason to touch the bytes.
The decision rule is therefore conditional: authorize and record in Next.js, transfer directly to a private bucket, confirm against a durable intent, and issue signed reads only after authorization. Choose proxying for mandatory pre-storage inspection. Choose multipart when interrupted large transfers need resumability. Choose a storage contract with verified retention and version recovery when an audit requirement makes accidental overwrite unacceptable.
That is the useful boundary. The browser moves bytes; the application decides what those bytes mean.
Top comments (0)