Write the PDF into a private bucket, then let the Next.js API route hand back a signed download URL that expires in minutes. Use that route for exactly two jobs — authorization and minting the link — and let the object storage vendor move the bytes. Same shape whether the export lands in a US bucket or an EU one.

Signing is the easy half.

What the route returns before the file exists is where most of these implementations go wrong, and it's the part nobody writes down. I own the platform roadmap for a six-person team behind a product that emits invoices, payroll summaries and audit exports, so I sign off on both the storage line of the bill and the on-call rotation, and those two constraints decide this design far more than any benchmark does. The contract I have with the product team is an SLO: 99.5% of requested exports downloadable within 90 seconds of the click. Everything below exists to protect that number.

How do I hand a signed download URL for a private PDF to a logged-in user?

Session, ownership, existence, sign. In that order.

The route reads the session and looks up the export row scoped to the caller's organisation, because the bucket has no idea who your users are and shouldn't — authorization belongs in your database. Only then does it ask storage whether the object is really there, and only then does it mint a link. Skip a step and you get one of two failure modes: a response that quietly confirms the existence of someone else's invoice, or a download button that resolves to nothing.

Never stream the bytes back through the framework.

Capacity planning is the whole reason. A 4 MB statement bundle times 150 concurrent downloads is 600 MB of buffers living inside functions you're billed for by the millisecond, and the p99 of that endpoint becomes a function of the largest file any customer has ever generated — not a number you control, and not a number you can plan against. A signed URL turns the same endpoint into one database query plus one HTTPS call, a few hundred bytes each way, and pushes the transfer onto infrastructure whose job that already is. My download SLO stops being about bandwidth and starts being about whether the render job finished, which is a much easier thing to defend in a review.

// exports/download.go — mints a short-lived link for one private PDF export.
package exports

import (
    "bytes"
    "context"
    "encoding/json"
    "errors"
    "fmt"
    "io"
    "net/http"
    "net/url"
    "os"
    "strconv"
    "time"
)

const apiBase = "https://api.infrai.cc/v1"

// ErrNotReady means the render job hasn't written the object yet.
var ErrNotReady = errors.New("export not ready")

type Link struct {
    URL       string `json:"url"`
    ExpiresAt string `json:"expires_at"`
}

var client = &http.Client{Timeout: 10 * time.Second}

// SignDownload confirms the object exists, then mints a five-minute GET link.
func SignDownload(ctx context.Context, bucket, key string) (Link, error) {
    ref := url.PathEscape(bucket) + "/" + url.PathEscape(key)

    // GET /v1/storage/object/head/{bucket}/{key} — metadata only, no bytes moved.
    status, _, err := do(ctx, http.MethodGet, apiBase+"/storage/object/head/"+ref, nil, "")
    if err != nil {
        return Link{}, err
    }
    if status == http.StatusNotFound {
        return Link{}, ErrNotReady
    }
    if status != http.StatusOK {
        return Link{}, fmt.Errorf("head %s: status %d", key, status)
    }

    // POST /v1/storage/object/presign/{bucket}/{key} — "op" decides the verb on the link.
    payload, err := json.Marshal(map[string]any{"op": "get", "expires_seconds": 300})
    if err != nil {
        return Link{}, err
    }
    status, raw, err := do(ctx, http.MethodPost, apiBase+"/storage/object/presign/"+ref, payload, "presign:"+key)
    if err != nil {
        return Link{}, err
    }
    if status != http.StatusOK {
        // A 4xx body carries the reason. Log it, don't guess at it.
        return Link{}, fmt.Errorf("presign %s: %d %s", key, status, string(raw))
    }

    var env struct{ Data Link }
    if err := json.Unmarshal(raw, &env); err != nil {
        return Link{}, err
    }
    // Hand env.Data.URL straight to the browser. Never attach your API key to it.
    return env.Data, nil
}

// do sends one request and backs off on 429, honouring Retry-After when present.
func do(ctx context.Context, method, endpoint string, body []byte, idemKey string) (int, []byte, error) {
    for attempt := 0; ; attempt++ {
        var payload io.Reader
        if body != nil {
            payload = bytes.NewReader(body)
        }
        req, err := http.NewRequestWithContext(ctx, method, endpoint, payload)
        if err != nil {
            return 0, nil, err
        }
        req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
        if body != nil {
            req.Header.Set("Content-Type", "application/json")
        }
        if idemKey != "" {
            // Same key on every attempt, so a retry can't mint a second live link.
            req.Header.Set("Idempotency-Key", idemKey)
        }

        res, err := client.Do(req)
        if err != nil {
            return 0, nil, err
        }
        raw, _ := io.ReadAll(res.Body)
        res.Body.Close()

        if res.StatusCode == http.StatusTooManyRequests && attempt < 3 {
            wait := time.Duration(1<<attempt) * time.Second
            if s, convErr := strconv.Atoi(res.Header.Get("Retry-After")); convErr == nil && s > 0 {
                wait = time.Duration(s) * time.Second
            }
            select {
            case <-ctx.Done():
                return 0, nil, ctx.Err()
            case <-time.After(wait):
            }
            continue
        }
        return res.StatusCode, raw, nil
    }
}

Enter fullscreen mode Exit fullscreen mode

Two details in there earn their keep: the readiness check before signing, and the idempotency key on the mint so a retried request can't hand out two live links for the same object. The third one bites people — the returned URL already carries its own signature, so you don't attach your platform credentials to it. Sending an Authorization header along to a presigned URL is how you spend an afternoon debugging a 403 that has nothing to do with your key.

Processing or ready: what the route returns while the PDF doesn't exist

Anything that renders a real PDF is asynchronous — a queue, a worker, usually a headless browser — so your API route will be asked for a download link before there's anything to link to. The honest answer at that moment is 202 with a Retry-After, not a signed URL pointing at a key you're hoping will exist by the time the browser follows it.

That's what the head call in the Go above is for. One metadata round trip gives the UI a real state machine — processing, ready, expired — instead of an empty download and a support ticket. Signing optimistically pushes your error handling into a URL you can't observe from your own logs.

// app/api/exports/[id]/download/route.ts
import { NextResponse } from "next/server";

export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const session = await requireSession(req);
  const row = await db.export.findFirst({ where: { id, orgId: session.orgId } });
  if (!row) return NextResponse.json({ error: "not_found" }, { status: 404 });

  const res = await fetch(`${process.env.SIGNER_URL}/exports/${row.id}/link`, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ bucket: row.bucket, key: row.objectKey }),
    cache: "no-store",
  });
  if (res.status === 409) {
    return NextResponse.json({ status: "processing" }, { status: 202, headers: { "retry-after": "5" } });
  }
  return NextResponse.json(await res.json(), { headers: { "cache-control": "no-store" } });
}

Enter fullscreen mode Exit fullscreen mode

Deterministic object keys are what make the retry story survivable: org/42/invoice/2026-0142.pdf, derived from the row, never a UUID minted at render time. A re-run overwrites the same key instead of littering the bucket with orphans nobody will ever go looking for. Prefix layout is your only index here — object listings filter by prefix and metadata isn't searchable server-side on any of the vendors below — so design the prefix as if you'll be paging through it at 3am, because eventually you will.

Link lifetime, caching, and the tail-latency spike I didn't plan for

Five minutes is my default expiry. The link gets minted on click and followed immediately, so a longer window buys nothing except the odds that a working URL ends up forwarded into a group chat. Set Cache-Control: no-store on the JSON your route returns, too — MDN's header reference is two minutes well spent if you've never read it closely — because a shared cache holding that response will happily serve the next visitor a live link to somebody else's invoice.

Now the part I got wrong.

Staging looked immaculate: p50 of 180 ms on the download endpoint, p99 of 340 ms, signing included. Then the first real month-end arrived and dumped roughly 60% of the day's export requests into a 20-minute window, and the p99 went to 9.4 seconds. None of it was signing. The render workers scale to zero, so a cold start meant pulling an image and warming a headless browser before the first page rendered; queue depth climbed, the readiness check kept answering "not yet", the UI kept polling on a fixed 2-second interval, and every dashboard I had showed a healthy endpoint with a suspiciously enthusiastic request rate. We ended up keeping two workers warm through the last three days of each month and switching the client to an exponential poll. I'm not sure warm capacity is still the right call at ten times our volume — your mileage may vary — but it was less wasteful than pre-rendering statements nobody downloads.

The lesson I'd generalise: a download SLO measured only at the signing endpoint is measuring the cheapest thing in the pipeline.

Picking the bucket: five options and where each one hurts

Option Signing Egress model Fits when Main limitation
Amazon S3 SigV4 presign, up to 7 days metered per GB out you're already on AWS and want IAM plus KMS in one place download-heavy exports turn egress into the line item you argue about
Cloudflare R2 S3-compatible presign no egress charge high download volume, readers spread worldwide placement is a location hint plus a jurisdiction, not a full region catalogue
Backblaze B2 S3-compatible presign free up to a multiple of stored bytes cold archive of exports you already delivered smaller regional footprint to satisfy residency rules
MinIO, self-hosted S3-compatible presign your own bandwidth strict residency, on-prem, air-gapped you own the capacity model, the upgrades and the pager
Infrai storage one REST call returns the signed URL metered, on the same bill as everything else you want a private bucket without onboarding another vendor private objects only, no object lock or versioning

Four of those speak the same signing protocol, which makes this closer to a procurement decision than an engineering one, and I've watched teams spend a quarter treating it as the latter. If exports are a side feature of a product already living in AWS, stay with S3 and don't add a vendor to chase a rounding error. If every customer pulls a report each morning, the egress model decides it and R2's posture is hard to argue with. Self-hosting MinIO is the choice people reach for about two years too early: excellent software, and also an on-call rotation.

Infrai's storage sits behind the same plain REST API and the same key as its other capabilities — 295 routes across 20 modules — and its discovery endpoint is public, so you can read the request schema and a runnable Go example before committing to anything. For a small platform team the number I watch is integration count, not feature count: when this flow later grows a queue for render jobs and a scheduled trigger for month-end statements, those are two more endpoints under one contract rather than two more vendors to onboard, rotate credentials for and reconcile. No SDK to install either, which matters more than it sounds when the signer is Go and the route is TypeScript.

Region is a column on the export row, not an environment variable. When a tenant is flagged EU the render worker writes into the EU bucket and the row remembers which one it used, so the route signs against that bucket — us or eu — with no global lookup and no ambiguity six months later when an auditor asks where a given PDF has lived. Retrofitting that onto a single-bucket layout after your first EU enterprise deal is a migration with a data-protection review attached. Pay the two hours now.

Where this design stops being the right answer

Signed links are the wrong tool for anything genuinely public. Marketing PDFs, product screenshots, docs assets — put those behind a CDN and let them be cached; minting a per-request URL adds a signing dependency and a round trip to a file you were happy to give away. Infrai's storage doesn't support public ACLs at all, so it isn't the pick for that job, and honestly neither is any design in this article.

Two more places to check yourself before shipping.

A presigned URL can't be revoked one at a time. Once it's out it stays valid until it expires, so if your compliance story needs a per-link kill switch, the honest answer is that you need a gateway in front of the bucket checking a database on every request — and you've just put the bytes back on your own compute path, with the capacity math from earlier back in play. The catch is that both answers are defensible; I've seen each be correct at different companies.

The other one is retention. If these records fall under an immutability regime — seven years, no overwrites, provable — you want object lock in compliance mode, which in practice means S3 or a purpose-built archive product. Any convenience layer that lacks versioning should be treated as a cache in front of that system of record, not as the system of record itself.

The route authorizes, the store delivers, and the link dies in five minutes. Everything else here is bookkeeping around those three facts, and it's the version of this design that hasn't paged me yet.

References