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 Action | Route Handler | Presigned PUT from the browser | |
|---|---|---|---|
| Body ceiling | 1 MB default, config-raisable | your runtime’s limit | vendor object limit |
| Client code | a <form action={…}> | fetch with FormData | fetch to a vendor URL |
| Bytes through your server | yes | yes | no |
| Works against an Infrai bucket | yes | yes | no — preflight is refused |
| Progress bar | no | with a stream | yes |
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.