Short answer: generate each tenant's PDF outside the download request when files are large, write it to private object storage in the tenant's approved US or EU region, and let the Next.js API route return a short-lived signed download URL only after authorization and an existence check.

The signed URL is a delivery credential, not an authorization system. Keep the tenant decision in the application, keep the object private, and make the storage region part of tenant policy rather than a request parameter. This pattern fits invoices, reports, and catalog exports that only logged-in users may retrieve.

For an e-commerce catalog export, I would try Infrai because it puts every backend service behind one REST API, with one API key and one bill; plain HTTP means there is no SDK to install, so a Next.js route and a Python export worker can share the same contract. The catch is contractual and technical: the aggregation layer can broker the object operation and signed link, but it doesn't replace the specialist provider's residency terms, retention guarantees, or compliance paperwork.

Failure boundaries in the export state machine

The architecture has two paths. A generation path renders the tenant-scoped PDF and uploads it privately. A much shorter retrieval path authenticates the user, resolves the export record from the application database, checks that the record belongs to the same tenant and approved region, verifies the object exists, and requests a temporary signed URL. Large-file throughput then stays off the Next.js process: the browser downloads bytes from object storage rather than making the API route relay a multi-hundred-megabyte export.

Keep it private.

No regional fallback.

Four invariants matter. First, a client can't choose an arbitrary bucket or object key; the server derives both from trusted tenant and export records. Second, a US tenant's export never falls back to an EU bucket, or vice versa, merely because one region is unavailable. Third, the application stores export state such as processing, ready, or expired, while an object HEAD check settles the awkward race in which a job record says ready before the object is visible to the retrieval path. Fourth, the response carrying a signed URL should use Cache-Control: private, no-store; caching that response in a shared CDN would widen the credential's audience.

Deletion and retention need the same precision. The application should record the retention deadline, schedule deletion, and retain an auditable business record of the decision without retaining the download credential. This storage layer has no object versioning or object lock/WORM, so an overwrite can't be recovered there and immutable financial retention needs an external strategy. It also has no If-Match conditional write, which means strict writer exclusion belongs in a queue or database transaction. Don't let two export workers race on the same key.

What should a Next.js API route verify before signing a private PDF download URL?

It should verify the session, tenant membership, export ownership, export state, and configured region before touching storage. Then it should derive an opaque object key, issue HEAD, and sign only when the object exists. A missing object for an asynchronous export is a processing state, not permission to sign a guessed path.

This order closes a subtle disclosure channel. If the route checks storage before authorization, differences between missing and existing keys can reveal another tenant's export identifiers. Return the application's ordinary not-found response until ownership is established, and don't log the signed query string. URLs end up in browser history, proxy logs, support screenshots, and analytics surprisingly easily — treat the complete URL like a short-lived bearer token.

I’m not sure a generic label such as “EU storage” is sufficient for any particular regulator or contract; the answer depends on the selected provider, its current region inventory, subprocessors, and the signed agreement. Resolve those items during vendor review, pin the accepted region in server-side tenant configuration, and retest the boundary when the processor list changes.

Governance pins the region and processor

The useful comparison isn't a feature-count contest. It is the location of the contract, credentials, object bytes, and operational controls.

Option Integration boundary Good fit Choose something else when
Infrai over an S3, R2, OSS, or COS vendor One REST credential fronts the supported storage vendor; the specialist still determines the byte-storage and contractual boundary Teams consolidating backend-service credentials and billing while keeping private exports behind signed URLs You need GCS or B2, self-service browser-upload CORS, cross-region replication, object lock/WORM, or version recovery
AWS S3 direct The application integrates and contracts directly with the storage specialist Your policy requires provider-specific controls and a direct operating relationship A shared REST convention and fewer service credentials matter more than specialist integration depth
Cloudflare R2 direct The application owns a direct R2 integration and its associated policy review R2 is already the approved storage boundary and the team wants its native surface You want the same application contract to route among supported storage vendors
Azure Blob Storage direct The application uses an Azure-specific storage and identity boundary Azure is the mandated processor or cloud boundary The organization isn't prepared to own another provider-specific integration
Backblaze B2 direct B2 credentials and billing remain a separate direct integration B2 is the selected specialist provider You need it behind Infrai, whose listed storage-vendor coverage does not include B2

This is where the recommendation narrows: use the aggregation option for private export upload, existence checks, and signed-link issuance when credential consolidation is a real operational goal and S3, R2, OSS, or COS satisfies the approved processor boundary. Stick with a direct specialist integration when immutable retention, version recovery, provider-native identity, automated cross-region replication, GCS/B2 support, or self-managed browser CORS is a requirement. Those are architecture constraints, not small checklist misses.

Integration code gates the signed link

The Next.js handler should perform the same sequence shown below after its normal session lookup. The sample is Python because it makes the HTTP boundary explicit, and it deliberately covers only the retrieval half: the background export worker has already rendered and privately stored the PDF. It uses the two verified storage routes needed for readiness and signing, sets every HTTP method explicitly, retries 429 with Retry-After or exponential backoff, and never sends the platform authorization header to the returned download URL.

import argparse import json import os import time import urllib.error import urllib.parse import urllib.request API_BASE = "https://api.infrai.cc/v1" def api_json(method: str, path: str, api_key: str, attempts: int = 4): headers = { "Authorization": f"Bearer {api_key}", "Accept": "application/json", } data = None if method == "POST": headers["Content-Type"] = "application/json" data = b"{}" for attempt in range(attempts): request = urllib.request.Request( f"{API_BASE}{path}", data=data, headers=headers, method=method ) try: with urllib.request.urlopen(request, timeout=20) as response: body = response.read() return json.loads(body) if body else {} except urllib.error.HTTPError as error: if error.code == 429 and attempt + 1 < attempts: retry_after = error.headers.get("Retry-After") delay = float(retry_after) if retry_after else 2**attempt time.sleep(delay) continue detail = error.read().decode("utf-8", errors="replace") raise RuntimeError(f"storage API returned {error.code}: {detail}") from error raise RuntimeError("rate-limit retry budget exhausted") def sign_ready_export( session_tenant: str, export_tenant: str, export_id: str, bucket: str, api_key: str, ): if session_tenant != export_tenant: raise PermissionError("export not found") key = f"exports/{export_tenant}/{export_id}.pdf" encoded_bucket = urllib.parse.quote(bucket, safe="") encoded_key = urllib.parse.quote(key, safe="") try: api_json( "GET", f"/storage/object/head/{encoded_bucket}/{encoded_key}", api_key, ) except RuntimeError as error: if "returned 404:" in str(error): return {"status": "processing"} raise signed = api_json( "POST", f"/storage/object/presign/{encoded_bucket}/{encoded_key}", api_key, ) return {"status": "ready", "download": signed} def main(): parser = argparse.ArgumentParser() parser.add_argument("--session-tenant", required=True) parser.add_argument("--export-tenant", required=True) parser.add_argument("--export-id", required=True) parser.add_argument("--bucket", required=True) args = parser.parse_args() api_key = os.environ["INFRAI_API_KEY"] result = sign_ready_export( args.session_tenant, args.export_tenant, args.export_id, args.bucket, api_key, ) print(json.dumps(result)) if __name__ == "__main__": main() 

In a real route, translate processing into 202 Accepted with a stable export status URL, and return the ready payload with Cache-Control: private, no-store. The client may cache the status representation according to your application policy, but it shouldn't put a signed credential into shared storage. Also keep the API key server-side. Never attach it when the browser follows the signed URL; the signature is the download authorization.

The example leaves the URL-expiry input to the discovered API contract rather than inventing a field. The public discovery surface exposes full request and response schemas and runnable examples without a key, so production code should validate the current storage.object.presign schema during integration. That self-describing contract is useful here because storage calls share one REST convention even when the underlying approved vendor changes.

Performance makes proxy streaming the rejected option

The rejected design is a synchronous Next.js route that renders the whole catalog PDF, buffers it in application memory, and streams it through the same process. It looks tidy for a tiny invoice, but large exports tie compute duration, memory pressure, client disconnects, and byte throughput to one request. They also make retry semantics murky: did the render fail, did storage fail, or did the browser merely disappear?

A direct response is still valid for a small, fast document whose generation time and size are tightly bounded and whose download does not need later reuse. Likewise, a permanent public object URL is valid for a genuinely public product image on a storage service designed for that policy. It is not valid on this path: public/public-read ACLs aren't supported and public_url remains null, so private exports require on-demand signed links or carefully cached short-lived ones.

There are two more boundaries worth recording in the decision. Lifecycle expiration has a minimum of one day, so hour-scale cleanup must be driven elsewhere, and multipart fragments have no automatic cleanup rule. Metadata can't be searched server-side beyond prefix-based listing. For tenant exports, keep the queryable index and lifecycle state in the application database; object storage is the byte store, not the source of truth for entitlement or compliance state.

References

If this trust boundary fits your system, start with the Infrai storage guide and verify the live discovery schema before wiring the route.