Building a folder browser on a bucket that has no folders
How prefix, delimiter and cursor turn a flat keyspace into a navigable tree on Infrai storage — plus the paging behaviour that will surprise you.
One parameter does almost all of the work: delimiter. Call GET /v1/storage/object/list/{bucket} with prefix set to the directory you’re showing and delimiter=/, and Infrai splits the flat keyspace into exactly the two lists a file browser needs — items are the files sitting at that level, and common_prefixes are the subfolders below it. Nothing recurses, nothing is materialised, and the call is free.
The mental model to keep is that u/42/Photos/ is a string, not a directory. Infrai storage stores keys, the slash is a convention, and every folder you render is inferred from the keys that happen to share a leading substring. That inference is fast and cheap, but it has consequences a Dropbox clone runs into within a week, so let’s build the browser first and then look at the sharp edges.
One directory, one call
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS "https://api.infrai.cc/v1/storage/object/list/user-drive?prefix=u/42/&delimiter=/&limit=100" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"items": [
{
"bucket_id": "bkt_fccb89d444004fc6aa1ed1",
"key": "u/42/readme.txt",
"size_bytes": 11,
"etag": "5eb63bbbe01eeed093cb22bb8f5acdc3",
"content_type": null,
"metadata": null,
"created_at": "2026-07-26T00:58:09.340848Z",
"last_modified": "2026-07-26T00:58:06Z"
}
],
"next_cursor": null,
"common_prefixes": ["u/42/Documents/", "u/42/Photos/"]
}
}
Two folders and one file — that’s a directory listing. Navigating into Documents/ is the same call with prefix=u/42/Documents/, and navigating up is prefix.split("/").slice(0, -2).join("/") + "/" with no network round-trip at all. Breadcrumbs are free because the path is the state.
Drop the delimiter and you get the recursive view instead: every key under the prefix, flat, which is what you want for a “download this folder as a zip” job and never what you want for a UI.
Same endpoint, different question.
The paging behaviour that will bite you
limit caps the underlying keys the server scans, not the rows you get back. So with a small limit and a delimiter, a page can legitimately return zero items and one prefix, and the same prefix can appear on several consecutive pages. Walking our test tree with limit=2 produced this:
page1: items [] common_prefixes ["u/42/Documents/"]
next_cursor "u/42/Documents/2026/q2-report.pdf"
page2: items [] common_prefixes ["u/42/Documents/", "u/42/Photos/"]
next_cursor "u/42/Photos/trip/img-1.jpg"
page3: items ["u/42/readme.txt"] common_prefixes ["u/42/Photos/"]
next_cursor null
Documents/ came back twice. If you push straight into an array your sidebar shows duplicates, so accumulate prefixes in a Set and keep calling until next_cursor comes back null — the cursor is an opaque-ish object key, and you pass it back verbatim.
// drive.mjs — Node 22 ESM
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
const auth = { Authorization: `Bearer ${KEY}` };
export async function readDirectory(bucket, prefix, { pageSize = 200 } = {}) {
const folders = new Set();
const files = [];
let cursor = null;
do {
const qs = new URLSearchParams({ prefix, delimiter: "/", limit: String(pageSize) });
if (cursor) qs.set("cursor", cursor);
const res = await fetch(`${API}/v1/storage/object/list/${bucket}?${qs}`, { method: "GET", headers: auth });
if (!res.ok) throw new Error(`list failed: HTTP ${res.status} ${await res.text()}`);
const { data } = await res.json();
for (const p of data.common_prefixes ?? []) folders.add(p);
for (const item of data.items ?? []) {
files.push({
name: item.key.slice(prefix.length),
key: item.key,
sizeBytes: item.size_bytes,
modified: item.last_modified,
});
}
cursor = data.next_cursor ?? null;
} while (cursor);
return {
path: prefix,
parent: prefix.split("/").filter(Boolean).slice(0, -1).join("/") + "/",
folders: [...folders].map((p) => ({ name: p.slice(prefix.length).replace(/\/$/, ""), prefix: p })),
files: files.sort((a, b) => a.name.localeCompare(b.name)),
};
}
Sorting happens inside that function for a reason, and it’s the reason most file managers eventually grow a database. The list endpoint returns keys in lexicographic order and doesn’t support sorting by size or modified date, doesn’t support filtering by content type, and has no notion of “recently opened” — so every column header your users expect to be clickable is work you do after the response arrives. For a folder of 50 files that’s free: sort the array and move on. For a tenant with 200,000 objects spread over 4,000 prefixes, “sort by newest” would mean paging the entire keyspace on every render, which is the point at which you stop treating the bucket as a database and start keeping a metadata table in Postgres — key, size, mime type, owner, modified — with the bucket holding nothing but bytes.
What a filesystem gives you that a keyspace doesn’t
| Operation | Filesystem | Bucket + list endpoint | What you build |
|---|---|---|---|
| Open a directory | readdir | prefix + delimiter=/ | nothing, it’s one call |
| Empty directory | exists | can’t exist — no key, no prefix | a folder row in your DB |
| Rename a directory | one rename | copy each object, then delete each | a job, not a request |
| Sort by date or size | stat + sort | lexicographic keys only | your own index |
| Folder size | du | sum over a recursive list | a counter you maintain |
| Move across buckets | n/a | POST /v1/storage/object/copy per object | a queued batch |
The empty-folder row is not a nitpick. Creating one by writing a zero-byte marker object doesn’t work either — the API requires a body:
{
"ok": false,
"error": {
"code": "INVALID_ARGUMENT",
"http_status": 400,
"message": "storage.object.put needs 'data' (base64 body)",
"retryable": false
}
}
Write a single-byte .keep if you must, but the honest answer is that “new folder” is a row in your database that the browser merges into the common_prefixes list before rendering. Users create empty folders constantly; storage has no idea what they mean.
Renaming a folder, properly
There’s no move operation, so a rename is a copy-then-delete over every key under the prefix. POST /v1/storage/object/copy handles one object and preserves its content type and metadata; POST /v1/storage/object/delete_batch/{bucket} clears up to 1000 keys per call and reports per-key failures instead of aborting.
// rename.mjs — Node 22 ESM
import { readDirectory } from "./drive.mjs";
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
const json = { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" };
async function everyKeyUnder(bucket, prefix, out = []) {
const dir = await readDirectory(bucket, prefix);
out.push(...dir.files.map((f) => f.key));
for (const folder of dir.folders) await everyKeyUnder(bucket, folder.prefix, out);
return out;
}
export async function renameFolder(bucket, from, to) {
const keys = await everyKeyUnder(bucket, from);
for (const key of keys) {
const move = { src_bucket: bucket, src_key: key, dst_bucket: bucket, dst_key: to + key.slice(from.length) };
const res = await fetch(`${API}/v1/storage/object/copy`, { method: "POST", headers: json, body: JSON.stringify(move) });
if (!res.ok) throw new Error(`copy ${key} failed: HTTP ${res.status}`);
}
const cleanup = await fetch(`${API}/v1/storage/object/delete_batch/${bucket}`, {
method: "POST",
headers: json,
body: JSON.stringify({ keys }),
});
if (!cleanup.ok) throw new Error(`delete_batch failed: HTTP ${cleanup.status}`);
return (await cleanup.json()).data;
}
Copies are per-object and billable, so renaming a 4,000-file folder is 4,000 calls — do it in a queue with the user seeing an optimistic name, not inside a request handler. And because the delete happens after the copies, a crash in the middle leaves both trees present rather than neither, which is the failure mode you want.
curl -sS -X POST "https://api.infrai.cc/v1/storage/object/delete_batch/user-drive" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"keys":["u/42/old/notes.md","u/42/old/missing.md"]}'
# {"ok":true,"data":{"deleted":["u/42/old/notes.md"],
# "errors":[{"key":"u/42/old/missing.md","code":"STORAGE_OBJECT_NOT_FOUND"}]}}
Partial success is the normal case, not the exception.
Cost, and when to use something else
Verified 26 July 2026: listing is free (rate-limited), as are head, presign and delete. You pay on POST /v1/storage/object/copy at $0.0001 per object and GET /v1/storage/object/get/{bucket}/{key} at $0.0002 per download — so a browser that lists a lot and downloads occasionally is almost entirely free to run. New accounts start with $2 of credit. Read the live figures, because they trend downward:
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| python3 -c "import json,sys; [print(c['id'], c['billing'].get('price_usd','free')) for c in json.load(sys.stdin)['capabilities'] if c['id'].startswith('storage.object')]"
The same semantics exist on S3 — ListObjectsV2 with a delimiter returns CommonPrefixes — so this design ports without a rewrite, and MinIO behaves the same way if you end up self-hosting. Supabase Storage is the honest alternative if you want folders, per-file rows and row-level security handed to you rather than assembled; its storage API keeps a database row per object, which is exactly the index this article tells you to build. Pick that if the file manager is your product.
If it’s a feature inside a larger app, the list endpoint plus a small metadata table is a couple of hundred lines, and it sits on the same key as the queue running your renames and the cron sweep expiring your trash folder.