Cheaper than Cloudinary or UploadThing: a plain object-storage upload

What managed uploader services charge for, why browser-direct PUT needs bucket CORS, and a working server-mediated upload path on Infrai with the honest limits.

If your files are generic — a PDF, a CSV, somebody’s holiday photo — you’re paying an uploader service for three things you may not need: a drop-zone widget, on-the-fly transformations, and per-GB bandwidth. Strip those and an upload is a signed request plus a PUT. Infrai exposes that as a REST route on the same key as the rest of your backend, and the bucket admin around it is free.

There’s one thing you should know before choosing it, and it’s a real constraint rather than a footnote: Infrai has no route that sets bucket CORS today, so a browser cannot PUT straight into an Infrai bucket. Read on for what that rules in and out.

What the managed uploaders actually sell

UploadThing and Cloudinary are not overpriced storage. They’re storage plus a client SDK, plus a callback, plus (for Cloudinary) an image pipeline that most teams would otherwise build badly. The bill only looks strange once you’re storing generic files and using none of the image features.

OptionCost shapeBrowser-direct?Who holds the credential
Cloudinaryper-GB storage + per-GB delivery + transformation creditsyes, widget includedtheir SDK, via an upload preset
UploadThingper-GB tiers with a free allowanceyes, SDK-managedtheir SDK + your app router
Cloudflare R2 + presigned PUT~$0.015/GB-month, no egress feeyes, once you set bucket CORSyour server signs, browser never sees a key
Backblaze B2~$6/TB-month, S3-compatibleyes, with CORS rulesyour server signs
Infrai storagefree bucket admin, per-call writes, GB-month rentnot today (no CORS route)your server; browser talks to your API

Those third-party rates are as published by each vendor and move often. Infrai’s own side of the table you can read live, and should:

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" | grep -o '"storage.object.put[^}]*}'

curl -sS "https://api.infrai.cc/v1/account/balance" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

A new account gets $2 free credit, which is a lot of small files, and storage pricing across the whole market has trended down for years — expect the live figure to be at or below what any article quotes.

The CORS wall, stated plainly

A browser PUT to a different origin is never a “simple request”. It triggers an OPTIONS preflight, and the bucket has to answer that preflight with the right Access-Control-Allow-* headers. Every bucket Infrai creates reports an empty CORS set, and no route in the storage namespace writes one:

curl -sS "https://api.infrai.cc/v1/storage/bucket/list" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "items": [
      {
        "bucket_id": "bkt_95be842d46f74fd2a62425",
        "name": "upload-inbox",
        "vendor": "cos",
        "acl": "private",
        "cors_rules": [],
        "lifecycle_rules": []
      }
    ]
  }
}

That empty cors_rules array is the whole story.

So the presign route is genuinely useful from a server or a native app, and useless from a web page. If browser-direct is non-negotiable — you’re uploading 500 MB videos from a laptop on hotel wifi and cannot pay for that transfer twice — use R2 or Supabase Storage, both of which let you set CORS and hand out a presigned PUT. That’s the honest answer, and it’s the one we’d give a friend.

The shape that works: one hop through your own API

For the file sizes most SaaS apps actually see (avatars, receipts, contracts, exports), routing bytes through your own handler is fine and buys you something: you get to authenticate the user, check the content type, cap the size, and attach the tenant id before a single byte reaches storage.

import { createServer } from "node:http";
import { Buffer } from "node:buffer";

const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
const BUCKET = "upload-inbox";
const MAX_BYTES = 10 * 1024 * 1024;
const ALLOWED = new Set(["image/jpeg", "image/png", "application/pdf", "text/csv"]);

async function store(key, bytes, contentType) {
  const payload = { data_base64: bytes.toString("base64"), content_type: contentType };
  const res = await fetch(`https://api.infrai.cc/v1/storage/object/put/${BUCKET}/${key}`, {
    method: "PUT",
    headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
    body: JSON.stringify(payload),
  });
  const json = await res.json();
  if (!json.ok) throw new Error(`${json.error?.code}: ${json.error?.message}`);
  return json.data;
}

const server = createServer((req, res) => {
  const contentType = req.headers["content-type"] ?? "";
  if (req.method !== "POST" || !ALLOWED.has(contentType)) {
    res.writeHead(415).end("unsupported media type");
    return;
  }
  const chunks = [];
  let size = 0;
  req.on("data", (c) => {
    size += c.length;
    if (size > MAX_BYTES) {
      res.writeHead(413).end("too large");
      req.destroy();
      return;
    }
    chunks.push(c);
  });
  req.on("end", async () => {
    try {
      const userId = req.headers["x-user-id"] ?? "anon";
      const key = `inbox/${userId}/${crypto.randomUUID()}`;
      const stored = await store(key, Buffer.concat(chunks), contentType);
      res.writeHead(201, { "Content-Type": "application/json" });
      res.end(JSON.stringify({ key: stored.key, size: stored.size_bytes, etag: stored.etag }));
    } catch (err) {
      res.writeHead(502).end(String(err.message));
    }
  });
});

server.listen(3000, () => console.log("upload API on :3000"));

The response you get back from storage carries everything you need to record the row:

{
  "ok": true,
  "data": {
    "bucket_id": "bkt_95be842d46f74fd2a62425",
    "key": "inbox/usr_77/photo.jpg",
    "size_bytes": 9,
    "etag": "6f10bd4744a69d1b56782c7f3734c189",
    "content_type": "image/jpeg"
  }
}

What the extra hop costs you

Time, mostly.

Base64 in a JSON body inflates the payload by a third, the bytes cross the network twice, and your handler holds the whole thing in memory while it does — so the approach stops being clever somewhere between “receipt” and “video”. Our measurements, one client on a home connection and the median of three runs each: a 240 KB file took roughly 2.3 s through the base64 route against 1.0 s for a presign plus a direct PUT issued from a server, a 9 MiB file took 10.4 s against 2.5 s, and a 30 MiB file took about 59 s on the base64 path, which is past any sensible request timeout and past most platform limits besides — Vercel’s serverless functions cap request bodies well below that, and a Cloudflare Worker will refuse the memory long before the clock runs out.

So: under 10 MB, one hop is fine and simple. Above that, presign from your server and stream the bytes yourself, or switch to multipart.

Confirm the object landed

GET /v1/storage/object/head/{bucket}/{key} is free and answers the only question that matters after an upload — is it there, and is it the size you expected:

curl -sS "https://api.infrai.cc/v1/storage/object/head/upload-inbox/inbox/usr_77/photo.jpg" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "found": true,
    "status": "found",
    "key": "inbox/usr_77/photo.jpg",
    "size_bytes": 9,
    "etag": "6f10bd4744a69d1b56782c7f3734c189",
    "content_type": "image/jpeg",
    "last_modified": "2026-07-26T01:00:55Z"
  }
}

Worth knowing before you rely on obscurity: we found stored objects answered an unsigned request as well, so a presigned link controls how long a URL stays usable, not who may fetch the bytes. Generate keys with crypto.randomUUID() — as the handler above does — and keep your API as the gate.

So which one should you buy

Pick UploadThing if the drop-zone, the progress bar and the callback are the work you’re trying to avoid; it’s a genuinely good few hours saved. Pick Cloudinary if you’ll be resizing and re-encoding, because you’d be rebuilding it otherwise. Pick R2 when transfer volume dominates and you need the browser talking to the bucket directly.

Pick Infrai when the upload is one small part of a backend that also sends the confirmation email, queues the virus scan, runs the cron sweep and records the error — all on one key, one bill, and one usage query. That’s the trade-off in a sentence: you give up browser-direct today, and you stop running five vendor accounts for one feature.

References

Browse more storage developer guides