Storage
Store the files your users upload — avatars, attachments, documents — in Cloudflare R2 behind your project's URL, and decide who may read and write them with row-level security policies, as in Supabase. naro-js calls it the way supabase-js does.
Early access. Storage runs on the project's backend: turn that on first with naro backend enable. Until Storage is switched on for the control plane, naro storage enable answers STORAGE_DISABLED.
Turn it on
naro storage enable makes the project's R2 bucket and uploads the backend again with it. It is safe to run again, and running it right after your team's plan changes applies the new limits at once, without waiting for the next check. naro storage bucket create turns Storage on first when it is off.
naro backend enablenaro storage enableBuckets
Files live in buckets you make: up to 100 per project, named with 1–63 lowercase letters, digits, - and _, starting and ending with a letter or a number; authenticated, copy, info, list, move, public, render, sign and upload are taken by the storage routes. A public bucket serves its files to anyone who has the URL; listing, uploading and every other call still go through the policies. A bucket can cap the size of a file and the types it takes.
naro storage bucket create avatars --public --file-size-limit 5MiB --allowed-mime-types image/png,image/jpeg,image/webpnaro storage bucket create docsWho may read and write
Storage's policies are Postgres row-level security on storage.objects, as in Supabase, and they are written the same way: in a migration, applied with naro db push. Without a policy only the service key gets through. storage.foldername(name), storage.filename(name) and storage.extension(name) work as in Supabase, and a policy on storage.objects can read your tables in a subquery.
-- migrations/0005_avatars.sql
create policy "avatars are readable" on storage.objects
for select to public using (bucket_id = 'avatars');
create policy "upload into own folder" on storage.objects
for insert to authenticated
with check (bucket_id = 'avatars' and (storage.foldername(name))[1] = auth.uid()::text);
create policy "replace own files" on storage.objects
for update to authenticated
using (bucket_id = 'avatars' and (storage.foldername(name))[1] = auth.uid()::text);
create policy "remove own files" on storage.objects
for delete to authenticated
using (bucket_id = 'avatars' and (storage.foldername(name))[1] = auth.uid()::text);naro db pushFrom your app
naro.storage.from(bucket) uploads, downloads, lists and removes files. Every call returns { data, error } and never throws. An upload sends the file's own bytes, up to 50 MiB, typed as the file is (or application/octet-stream); upsert: true replaces a file that is there.
import { createClient } from "naro-js";
const naro = createClient({
url: "https://my-app.naro.sh",
anonKey: "naro_pk_…",
});
const {
data: { user },
} = await naro.auth.getUser();
const avatars = naro.storage.from("avatars");
await avatars.upload(`${user.id}/me.png`, file, { upsert: true });
const { data: image } = avatars.getPublicUrl(`${user.id}/me.png`); // no request
await avatars.list(user.id, { limit: 20 });
await avatars.remove([`${user.id}/old.png`]);
// A private bucket: a link for ten minutes.
const { data: link } = await naro.storage
.from("docs")
.createSignedUrl("team-1/plan.pdf", 600);Public and signed URLs
getPublicUrl builds a public bucket's URL without a request. createSignedUrl links to a file the caller can read, for expiresIn seconds (up to 7 days); the link stops working once the file is replaced or removed.
From the terminal
naro storage ls, cp and rm work on naro://<bucket>/<path> with the project's service key — NARO_SERVICE_KEY, or fetched for you with a warning. The bytes go straight to your project's backend, not through naro. cp goes both ways and overwrites a local file; replacing an object that is already there takes --upsert. rm asks first, and --recursive removes everything under a folder: naro://avatars/u1/ is the folder alone, naro://avatars/u1 takes an object named u1 as well.
naro storage cp ./me.png naro://avatars/u1/me.pngnaro storage ls naro://avatars/u1naro storage rm naro://avatars/u1/old.pngComing from Supabase
The method names, arguments and results are supabase-js's, FileObject's snake_case fields included. What is different:
- supabase-js's upload(path, file) sends a multipart/form-data form. Naro takes the file's own bytes as the body and refuses a form with 400 INVALID_REQUEST, so switch the client to naro-js, or send the bytes with fetch as below.
- An upload is typed as the file is (Blob.type). Bytes with no type are application/octet-stream, not supabase-js's default text/plain, and a string is text/plain;charset=utf-8.
- info answers the same snake_case FileObject as list, with the size and type in metadata.size and metadata.mimetype; supabase-js's info answers camelCase fields (bucketId, createdAt, size, contentType).
- Errors are NaroError { code, message, details? }, like the rest of naro-js.
- Buckets are made with naro storage bucket create or the MCP server, not from the client.
- move, copy, createSignedUrls, list's search and files over 50 MiB are not here yet.
- A path keeps every character as written — Korean and emoji included, nothing normalized; . and .. segments, empty segments, a / at either end, control characters, a backslash (\) and bidirectional control characters (U+202E and the like) are refused.
- Two policy shapes from Supabase's docs are refused when the migration is pushed: write (select auth.uid())::text, not (select auth.uid()::text), and use storage.foldername(name) only as (storage.foldername(name))[n] with n from 1 to 16 — not inside any(...). storage.allow_only_operation and storage.allow_any_operation are not available.
Without naro-js, send the file's bytes as the body:
await fetch(
`https://my-app.naro.sh/_naro/v1/storage/object/avatars/${user.id}/me.png`,
{
method: "POST",
headers: {
apikey: "naro_pk_…",
authorization: `Bearer ${token}`, // a signed-in user's token
"content-type": file.type,
"x-upsert": "true",
},
body: file,
},
);Limits
| What | Limit |
|---|---|
| One upload | 50 MiB |
| All files of a project | 1 GiB on Hobby, 100 GiB on Pro, 1 TiB on Enterprise |
| Write operations a day (UTC) | 50,000 on Hobby, 500,000 on Pro, 5,000,000 on Enterprise |
| Buckets | 100 per project |
| A file's path | 900 bytes of UTF-8 |
| A signed URL | 1 second to 7 days |
The capacity follows your team's plan, checked every 15 minutes; past it, uploads answer 507 STORAGE_QUOTA_EXCEEDED until you delete files. Every upload counts as a write operation, and past the day's limit uploads answer 429 STORAGE_OPS_QUOTA_EXCEEDED until 00:00 UTC. The size comes from R2 itself, which reports it 30 minutes to about an hour and a half late: after you delete files, uploads reopen only once the new size has been reported and checked. Neither limit stops downloads or deletes. A file over 50 MiB answers 413 OBJECT_TOO_LARGE before any byte is sent.
Off and deleted
naro backend disable stops Storage with the rest of the backend: its URLs answer 404 until it is on again, and the files are kept. Deleting the project deletes its bucket and every file in it: an empty bucket at once; otherwise every file is set to expire within a day, and the bucket is deleted after that. Deleted files cannot be restored.