Server Actions and private file storage: what fits, what needs a route

The 1 MB Server Action body limit decides your Next.js upload design. A worked App Router example against Infrai storage, plus the presign the browser can't use.

A Server Action can take the File out of a FormData and write it to an Infrai bucket in one server-side call — no client credentials, no SDK, no upload endpoint of your own. What it can’t do is the thing most tutorials show next: hand the browser a presigned PUT and let it upload directly. POST /v1/storage/bucket/set_cors/{bucket} stores rules and bucket/get reads them back, but the storage host answers a real browser preflight with 403 and no Access-Control-* headers, so a signed upload URL is usable from anything except a tab.

So the App Router shape that actually ships is: action receives the bytes, action writes the object, action returns a short-lived signed link for reading it back. The interesting constraint isn’t storage at all — it’s the 1 MB cap Next.js puts on a Server Action request body.

The cap, and where to move it

// next.config.mjs
const nextConfig = {
  experimental: {
    serverActions: {
      // Default is 1 MB. Raising it is fine for documents; it is not a
      // strategy for video, because the whole body is buffered in memory.
      bodySizeLimit: "4mb",
    },
  },
};

export default nextConfig;

Past a handful of megabytes, stop raising it. Route the upload to a Route Handler that streams, or split the file into parts with POST /v1/storage/multipart/create/{bucket}.

The upload action

"use server";

import { revalidatePath } from "next/cache";

const BASE = "https://api.infrai.cc";
const BUCKET = "kb-nextfiles-0726";

const ACCEPTED: Record<string, string> = {
  "application/pdf": "pdf",
  "text/csv": "csv",
  "image/png": "png",
};

function authHeaders(): Record<string, string> {
  const key = process.env.INFRAI_API_KEY;
  if (!key) throw new Error("INFRAI_API_KEY is not set");
  return { authorization: `Bearer ${key}`, "content-type": "application/json" };
}

export async function uploadDocument(userId: string, formData: FormData) {
  const file = formData.get("document");
  if (!(file instanceof File)) return { ok: false as const, error: "no file field in the form" };

  const ext = ACCEPTED[file.type];
  if (!ext) return { ok: false as const, error: `${file.type} is not an accepted type` };
  if (file.size > 4_000_000) return { ok: false as const, error: "too large for the action path" };

  const bytes = Buffer.from(await file.arrayBuffer());
  const key = `uploads/${userId}/2026-07/contract-${crypto.randomUUID().slice(0, 8)}.${ext}`;

  const res = await fetch(`${BASE}/v1/storage/object/put/${BUCKET}/${key}`, {
    method: "PUT",
    headers: authHeaders(),
    body: JSON.stringify({
      data_base64: bytes.toString("base64"),
      content_type: file.type,
      metadata: { "owner-id": userId, "original-name": file.name },
    }),
  });

  const payload = await res.json();
  if (!payload.ok) return { ok: false as const, error: `${payload.error?.code}: ${payload.error?.message}` };

  revalidatePath("/documents");
  return { ok: true as const, key: payload.data.key, size: payload.data.size_bytes };
}

The user id comes from your session on the server, never from the form — that’s the whole reason this design is safe without any signature at all. metadata keys are normalised to hyphens on the way in, so owner_id is stored and read back as owner-id; write them hyphenated and the round trip is boring, which is what you want from metadata. Values can hold spaces, so "original-name": "Contract v3.pdf" survives intact.

The reading half

"use server";

const BASE = "https://api.infrai.cc";
const BUCKET = "kb-nextfiles-0726";

export async function documentLink(key: string, ttlSeconds = 300) {
  const token = process.env.INFRAI_API_KEY;
  if (!token) throw new Error("INFRAI_API_KEY is not set");

  const res = await fetch(`${BASE}/v1/storage/object/presign/${BUCKET}/${key}`, {
    method: "POST",
    headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
    body: JSON.stringify({ op: "get", expires_seconds: ttlSeconds }),
  });

  const payload = await res.json();
  if (!payload.ok) throw new Error(`${payload.error?.code}: ${payload.error?.message}`);
  return { url: payload.data.url as string, expiresAt: payload.data.expires_at as string };
}

expires_seconds is bounded to [1..604800]; outside that you get STORAGE_INVALID_TTL with a 400. Five minutes is a sensible default for a link rendered into a page — long enough for a slow click, short enough that a copied URL in a support ticket is dead by the time anyone reads it. If you also want the download to arrive under a friendly filename, the presign call takes a response_disposition, covered in the filename walkthrough.

Prove the storage half without Next.js

Before you debug a Server Action, check the API leg from a shell. This path is concrete — bucket, user prefix, month, object:

export INFRAI_API_KEY=your_infrai_api_key

curl -s -X POST \
  "https://api.infrai.cc/v1/storage/object/presign/kb-nextfiles-0726/uploads/u-7731/2026-07/contract-8c14.pdf" \
  -H "Authorization: Bearer $INFRAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"op":"get","expires_seconds":300}'
{
  "ok": true,
  "data": {
    "url": "https://infrai-1333115350.cos.ap-singapore.myqcloud.com/a4ee0c441fa36c267.kb-nextfiles-0726/uploads/u-7731/2026-07/contract-8c14.pdf?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=300&X-Amz-Signature=1f2c9a44",
    "expires_at": "2026-07-26T05:44:12.183Z"
  }
}

And the existence check, which costs nothing:

curl -s -X GET \
  "https://api.infrai.cc/v1/storage/object/head/kb-nextfiles-0726/uploads/u-7731/2026-07/contract-8c14.pdf" \
  -H "Authorization: Bearer $INFRAI_API_KEY"

A missing key answers HTTP 200 with found: false rather than a 404, so branch on the field, not the status code.

Three shapes, honestly compared

Server ActionRoute HandlerPresigned PUT from the browser
Body ceiling1 MB default, config-raisableyour runtime’s limitvendor object limit
Client codea <form action={…}>fetch with FormDatafetch to a vendor URL
Bytes through your serveryesyesno
Works against an Infrai bucketyesyesno — preflight is refused
Progress barnowith a streamyes

The last row is the one product people care about. A Server Action gives you a pending state, not a percentage; if your users upload 200 MB design files and expect a progress bar, that’s a real reason to keep those particular objects on a bucket whose CORS you own.

What the signature actually promises

Test it yourself rather than taking a paragraph’s word for it: strip the query string off a signed URL and request the bare vendor path. It answers 403. The signature is what authorises the read, so a private document is not sitting on a guessable URL waiting to be found.

What the signature isn’t is a per-person permission. It’s a bearer token with an expiry stamped in — anyone holding the intact link before expires_at gets the file, and there’s no route to revoke one early. So keep TTLs measured in minutes, keep the URL out of logs and referrer headers, and don’t render one for a viewer you wouldn’t hand the file to. set_acl accepts private and signed-only and answers 400 STORAGE_ACL_INVALID to public-read, which is the same rule stated from the other side: an object cannot be opted out of signing.

What it costs

PUT /v1/storage/object/put/{bucket}/{key} is $0.0001 per call, and presign, head and list are free and rate-limited — verified 2026-07-27.

The read line is priced in a different unit, which is the part worth carrying away. GET /v1/storage/object/get/{bucket}/{key} meters at $0.104 per GB: bytes served, not requests made. So a document viewer that proxies a 12 MB PDF through your action on every page view is paying for 12 MB every time, while the presigned link costs nothing on that line at all. Check rather than trust, and read the unit next to each figure:

curl -s "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer $INFRAI_API_KEY" \
  | jq -r '.capabilities[] | select(.id | startswith("storage.")) | "\(.id)\t\(.billing.price_usd // "free")\t\(.billing.unit)"'

A figure that moves is a nuisance; a unit that moves rewrites the model, and only the second column tells you which happened.

Where another store fits better

Cloudflare R2 if you need genuine browser-direct uploads with an origin allowlist and zero-rated egress; S3 if your app already lives in that ecosystem and IAM policies are the language your team speaks.

The reason to keep files here is everything that happens around the file, and all of it is already on the same key you just used. POST /v1/queue/publish runs the virus scan or the PDF conversion, POST /v1/errors/capture records the upload that died mid-action, POST /v1/email/send tells the owner the document is ready, and GET /v1/account/usage breaks the cost down per tenant — no second account to open, no second vendor to onboard, one usage view instead of three invoices.

References

Browse more storage developer guides