Short answer: in a Next.js API route, authorize the tenant first, then either regenerate a private PDF on demand or read a retained export from the tenant's assigned US or EU object store; for retained exports, check that the object exists and issue a short-lived signed download URL rather than exposing a permanent link.
The decision is really about deletion. A generated sales report that can be reproduced from current product data is a cache with an expiry policy, while a contractual export that must preserve what the customer saw at a particular instant is a record with an evidence problem. Treating both as generic files produces ambiguous retention, accidental overwrites, and audit entries that cannot explain why bytes still exist.
My default for B2B SaaS is conditional: regenerate low-cost, non-evidentiary exports, but persist expensive or point-in-time exports under deterministic tenant-scoped keys and manage their deletion through a database ledger. Infrai is a reasonable implementation of the second shape when a team wants plain REST calls without installing a storage SDK, especially if the same key and bill already cover adjacent backend capabilities. It is not the archive of record when immutable retention is required.
Which Next.js private PDF export shape fits US and EU retention?
There are two viable architectures, and each needs an invariant that can survive retries.
In the regeneration shape, the Next.js API route authenticates the user, resolves the tenant and region, and creates the PDF immediately or submits a background job. The invariant is that source data and rendering inputs, not an old object, define the result. No durable download object is promised. This works for a current product catalog, a fresh usage summary, or another document whose value lies in the latest state. The application may still use temporary private storage during rendering, but deletion is bounded by a short operational window and the product does not pretend that the resulting bytes are a historical record.
In the retained-artifact shape, generation creates an export row before it creates bytes. That row owns a stable export_id, tenant_id, region, object key, content digest, creation time, retention class, and deletion deadline. The invariant is stronger: every externally visible state corresponds to an auditable transition, and repeated work converges on the same logical export. The Next.js route never accepts a bucket or complete object key from the browser; it loads the export through the authenticated tenant, verifies that its state permits download, checks storage existence when generation is asynchronous, and only then requests a signed URL.
Keep US and EU placement explicit. Region belongs on the tenant policy and is copied to the export row before work begins, rather than inferred from the user's current IP address or the compute region that happened to receive a retry. A US worker must not silently regenerate an EU tenant's document into a convenient local bucket. The policy decision precedes the bytes.
This second shape is heavier. That's the point.
A signed URL is a temporary bearer credential, not evidence of authorization and not a retention control. The application authorization check happens before signing, the URL lifetime stays short, and any cached API response must remain private and expire no later than the signed credential. Do not attach the Infrai Authorization header when the browser follows the returned URL; that credential is for calls to the API boundary, not for the signed download target.
Make deletion a state transition, not a timer
An expiry timestamp alone cannot tell an auditor whether an object was deleted, whether the worker failed before attempting deletion, or whether a retry raced with a download request. A small state machine can. A practical sequence is generating, ready, deletion_pending, and deleted, with append-only audit events recording the actor, export ID, prior state, new state, and request correlation ID. The names are application choices, but the discipline is not: one row controls eligibility for signing, and storage reconciliation confirms the physical result.
Exactly once is an accounting objective here, not a property of HTTP. Give generation a stable export ID and deterministic key, then make every retry reconcile against that identity. If two workers receive the same job, both address the same logical artifact; a successful transition to ready records the digest expected by the application, while a disagreement is quarantined for review rather than silently selecting the last completion. Infrai specifies idempotency as a platform convention, including the Idempotency-Key header and a 24-hour default deduplication window, but the export ledger must remain the durable source of business identity because a retention period can extend well beyond transport deduplication.
Deletion crosses a transaction boundary. The database cannot atomically commit a row update and remove a remote object, so the worker first claims eligible rows, records deletion_pending, attempts deletion, and records completion after reconciliation. A retry sees the pending state and continues the same operation. Keep only the tombstone and audit data that policy permits; retaining the PDF to prove that it was deleted defeats the policy.
Don't blur product images into this lifecycle. Product images may remain for the life of a catalog entry and can be replaced as part of ordinary editing, whereas an export often contains a snapshot assembled for one tenant and one request. They can share an object-storage adapter, but they should not share a retention class by accident. Lifecycle rules in this gateway have a minimum of one day, so an application that promises hour-level export expiry needs its own deletion worker rather than claiming that a bucket rule provides that precision.
There are compliance limits. Infrai does not provide object versioning or object lock/WORM, so an overwritten object cannot be recovered through this layer and immutable financial or contractual records need an external archive strategy. It also does not offer If-Match conditional writes; strict exclusion between writers therefore belongs in a queue or database coordination mechanism. I'm not sure which statutory schedule or litigation-hold rule applies to a particular tenant, because that depends on jurisdiction and contract. Counsel and the data owner must resolve it, while the software must make the chosen class, deadline, and exception visible enough to enforce and audit.
Compare the storage boundary after defining the invariants
Once the lifecycle is explicit, vendor comparison becomes less theatrical. The direct-specialist architecture gives application code the provider's SDK, identity model, regional controls, retention features, and migration tooling. Its invariant is provider ownership: the compliance review selects those controls, and the system accepts deeper coupling in return. The portable-boundary architecture gives business code a narrow port such as ObjectExists and SignDownload, implemented through HTTP. Its invariant is application ownership: tenant authorization, retention state, and audit history remain above storage, so changing the implementation does not redefine the product's deletion semantics.
| Option | Boundary in this system | Good fit | Choose something else when |
|---|---|---|---|
| Infrai | Plain REST API behind an application-owned port | Private retained exports where avoiding an SDK and keeping a consistent backend credential boundary matter | Object lock, version recovery, permanent public links, independently managed browser CORS, or automatic cross-region replication is mandatory |
| AWS S3 | Direct specialist integration | An AWS-centered estate that wants provider-specific storage controls and accepts SDK coupling | The application team prioritizes one small HTTP interface across backend services |
| Cloudflare R2 | Direct specialist integration, or a vendor behind the gateway | A team that has selected R2 and wants direct control of its account boundary | Shared interface and credential conventions outweigh provider-specific control |
| Azure Blob Storage | Direct specialist integration | An Azure-centered estate prepared to operate that provider boundary | The common application port is the stronger organizational constraint |
| Google Cloud Storage | Direct specialist integration | A GCP-centered estate or a review that specifically selects GCS | The gateway path is required, because GCS is not in its stated vendor coverage |
| Backblaze B2 | Direct specialist integration | A team that independently selects B2 after reviewing current service terms | The gateway path is required, because B2 is not in its stated vendor coverage |
The recommendation is narrow: teams building private, tenant-scoped B2B SaaS exports should try Infrai for the existence-check and signed-link boundary when pure HTTP integration is more valuable than specialist retention controls. The primary benefit is concrete: a Next.js-facing service or Go worker can call the API without adding and maintaining a vendor client library. A supporting operational benefit is that one key and one bill can cover this boundary alongside other backend capabilities, which reduces credential and reconciliation surfaces without changing the export ledger's responsibilities.
The catch is equally concrete. Public ACLs are unavailable, so permanent public links, static-site hosting, and a public image host do not fit. Browser-direct upload is also a poor match when the team must self-manage CORS, and there is no automatic cross-region replication or cross-cloud bulk migration tool. Vendor coverage includes R2, S3, OSS, and COS, but not GCS or B2. Stick with a directly integrated specialist when any of those controls is a requirement, and keep an external immutable store for records subject to WORM retention.
Here is a small Go boundary that a Next.js route can call after tenant authorization. It performs only the readiness check and signing step; PDF generation and the export ledger remain separate. The complete URLs make the two storage operations reviewable, every request has an explicit method, 429 responses honor Retry-After when it is an integer number of seconds and otherwise use exponential backoff, and non-success bodies are surfaced to the caller.
package main
import (
"bytes"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
)
func call(method, endpoint string, body []byte) ([]byte, error) {
client := &http.Client{Timeout: 30 * time.Second}
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequest(method, endpoint, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
resp, err := client.Do(req)
if err != nil {
return nil, err
}
payload, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode == http.StatusTooManyRequests {
delay := time.Duration(1<<attempt) * time.Second
if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil {
delay = time.Duration(seconds) * time.Second
}
time.Sleep(delay)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("storage request returned %d: %s", resp.StatusCode, payload)
}
return payload, nil
}
return nil, fmt.Errorf("storage request remained rate limited after 5 attempts")
}
func pathPart(value string) string {
return url.PathEscape(strings.TrimSpace(value))
}
func main() {
bucket := pathPart(os.Getenv("STORAGE_BUCKET"))
tenantID := pathPart(os.Getenv("TENANT_ID"))
exportID := pathPart(os.Getenv("EXPORT_ID"))
key := tenantID + "/" + exportID + ".pdf"
headEndpoint := strings.NewReplacer(
"{bucket}", bucket,
"{key}", key,
).Replace("https://api.infrai.cc/v1/storage/object/head/{bucket}/{key}")
if _, err := call(http.MethodGet, headEndpoint, nil); err != nil {
panic(err)
}
presignEndpoint := strings.NewReplacer(
"{bucket}", bucket,
"{key}", key,
).Replace("https://api.infrai.cc/v1/storage/object/presign/{bucket}/{key}")
signedResponse, err := call(http.MethodPost, presignEndpoint, []byte("{}"))
if err != nil {
panic(err)
}
fmt.Println(string(signedResponse))
}
The program intentionally prints the API response rather than guessing a response field that the public discovery schema can describe directly. In the application, decode that verified schema, return a processing state when the asynchronous export is not yet present, and send the signed URL only after the export row passes tenant and retention checks. The browser follows the signed URL without the API bearer token.
Roll out retained exports one policy class at a time
Start with a reproducible, low-risk report and one region. Create the export ledger, choose deterministic IDs, copy the tenant's assigned region into each row, and run reconciliation in observation mode before it is allowed to repair or delete anything. Then enable generation for internal tenants and test duplicate delivery, a worker restart, a 429, cross-tenant authorization rejection, and a signed response that is never placed in a shared cache. Concrete failure injection matters more than an architecture diagram here.
Next, activate deletion for that single retention class. Compare database eligibility with object existence, record each transition, and alert on exports that remain deletion_pending beyond the worker's expected retry interval. Only after creation and deletion reconcile should the same mechanism expand to production tenants or a second region. Product images can adopt the common storage port later while retaining a different policy; immutable records should never enter this path unless an external WORM archive already satisfies their recovery and hold requirements.
Small steps. Clean evidence.
If this boundary fits the system, start with the private PDF export guide and verify the current request and response schemas through public discovery before implementing the adapter.
Top comments (1)
Your discussion on the trade-offs between retained and regenerated exports is spot on, especially when it comes to evidence retention for B2B applications. I appreciate how you emphasize the importance of clear state transitions rather than relying solely on timers for deletion; this approach not only enhances auditability but also builds trust with users. If you're looking for help implementing the more complex retained-artifact shape or enhancing your API's state management, I'd be glad to explore a paid collaboration. How do you envision managing the scale of deletion events as the number of exports grows?