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. Infrai buckets expose no CORS setter, so that preflight fails, and a signed upload URL is only usable from something that isn’t a browser.

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 have to be hyphenated; an underscore comes back as a 503 from the storage backend, which is not a hint you’d guess from the message. Values can hold spaces, so "original-name": "Contract v3.pdf" survives the round trip 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

Not access control. We tested this directly: strip the query string off a signed URL, request the bare vendor path, and the object still comes back with HTTP 200 — even after setting the object’s ACL to signed-only. The expiry limits how long a link works; the unguessable key is what keeps strangers out. Treat the URL as a secret, keep it out of logs and referrer headers, and don’t hand one to a client you wouldn’t hand the file to.

What it costs

Writing an object is $0.0001 per call, a server-side read through GET /v1/storage/object/get/{bucket}/{key} is $0.0002, and presign, head and list are free — verified 2026-07-26. Prices trend downward here, so check rather than trust:

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")"'

Fetching bytes through the signed URL is vendor bandwidth rather than a per-call charge, which is why rendering a link beats proxying the file for anything a user downloads more than once.

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 around the file — the same key runs the queue that converts it, the email that tells the owner it’s ready, and one usage view instead of three invoices.

References

Browse more storage developer guides