TL;DR: Every LingCode Cloud backend includes file storage backed by S3-compatible object storage (with an optional CDN). There are two buckets: public (CDN-served, world-readable) and private (user-scoped, requires the signed-in user's token). You upload with lingcode.storage.from('public').upload(path, file) and the SDK automatically routes small files inline and large files straight to object storage via a presigned PUT โ so GB-scale video and audio just work.
Most apps need somewhere to put files: avatars, photo uploads, generated PDFs, audio clips. Storage is the part of your backend that holds those bytes. The design question is almost always the same โ is this file something the whole world can see by URL, or is it private to one user? LingCode answers that with two buckets, and the rest of this guide is about picking the right one and uploading without thinking about file size.
A file's visibility is a property of where you put it, not a per-request decision you make later. That keeps the rules simple and impossible to get subtly wrong:
public โ served through a CDN and world-readable. Anyone with the URL can fetch the file. This is for assets you intend to embed by URL: avatars, public images, marketing media, anything you'd happily drop into an <img> tag. Public objects get a stable URL you can compute synchronously with getPublicUrl(path).private โ user-scoped. Reading a private object requires the signed-in user's token; there's no public URL. This is for per-user documents โ uploaded contracts, private photos, anything that should never leak by guessing a URL. Gate reads with the user token and ownership the same way you'd gate a private database row.You address a file by its path within a bucket โ for example avatars/me.png in the public bucket. Object keys are namespaced per backend and per bucket on the server, and any path traversal (. / ..) is stripped, so you can't reach into another backend's files.
If you'll embed it by URL and don't mind anyone fetching it, use public and render getPublicUrl(path). If it belongs to one user and reads must be authenticated, use private and pair it with RLS-style ownership on the rows that point at it. When in doubt, default to private โ you can always copy to public later.
Call lingcode.storage.from(bucket) to get a bucket handle, where bucket is 'public' or 'private'. Every method returns the same { data, error } Result shape as the rest of the SDK (except getPublicUrl, which is synchronous and returns a plain string).
const bucket = lingcode.storage.from('public');
// upload(path, file, { contentType? }) -> { data: { bucket, path, bytes, url }, error }
// file may be a Blob | File | ArrayBuffer | string
await bucket.upload('avatars/me.png', file, { contentType: 'image/png' });
// download(path) -> { data: Blob, error }
const { data: blob } = await bucket.download('avatars/me.png');
// getPublicUrl(path) -> string (synchronous; only meaningful for the public bucket)
const url = bucket.getPublicUrl('avatars/me.png');
// remove(path) -> { data: { removed: boolean }, error }
await bucket.remove('avatars/me.png');
You only ever call one method โ upload() โ and the SDK decides how to move the bytes based on size. This is the single most important thing to understand about Storage, because it's what lets the same one-line call handle both a 40 KB avatar and a 2 GB screen recording.
The SDK hides this split entirely. If you're calling REST yourself, the large path is a deliberate two-step: create-upload-url โ PUT to the returned URL โ finalize (covered below). For very large media, just call upload() and let the SDK take the presigned path automatically.
Pushing a 2 GB file through your application server means that server has to buffer and stream 2 GB โ slow, memory-hungry, and easy to time out. A presigned PUT sidesteps the server entirely: the browser talks straight to object storage over a URL that's only valid for your specific object for a short window. You get GB-scale uploads for free without the gateway ever touching the bytes.
Storage is metered on four axes, and the ceilings depend on your plan tier. Exceeding any cap returns HTTP 402 quota_exceeded. The backend warns at 80% of your storage allowance and flags critical at 95%, so you'll see pressure before you hit the wall.
| Limit | What it caps | Free | Pro | Max Pro |
|---|---|---|---|---|
maxObjectBytes |
Inline upload, per object | 1 MB | 5 MB | 10 MB |
maxUploadBytes |
Direct presigned-PUT, per object | 50 MB | 1 GB | 5 GB |
maxStorageBytes |
Total stored bytes per backend | 500 MB | 5 GB | 20 GB |
maxObjects |
Object count per backend | 50 | 1,000 | 10,000 |
The single-PUT ceiling is 5 GB regardless of tier โ that's the largest object a direct presigned PUT can write. Full numbers, alongside the rest of the plan limits, live on the pricing page.
upload() returns error.code === 'quota_exceeded', you've hit one of the four caps โ too many objects, too many total bytes, or a single object over the per-object limit for your tier. Surface it to the user (or prune old objects with remove()) rather than retrying.
Avatars are the textbook public case โ you want a stable URL you can drop into an image tag. Upload, then read back the public URL synchronously (or just use the url the upload returns).
const file = document.querySelector('#avatar-input').files[0];
const { data, error } = await lingcode.storage
.from('public')
.upload(`avatars/${user.id}.png`, file, { contentType: file.type });
if (error) {
console.error('upload failed', error);
} else {
// data = { bucket, path, bytes, url }
document.querySelector('#avatar').src = data.url;
}
// Or compute the stable URL at any later time, without re-uploading:
const url = lingcode.storage.from('public').getPublicUrl(`avatars/${user.id}.png`);
A user's private files go in the private bucket. There's no public URL โ reads require the signed-in user's token, so make sure you're signed in (see the Auth guide). Fetch it back with download(), which returns a Blob.
// Upload โ scoped to the signed-in user
const { error } = await lingcode.storage
.from('private')
.upload(`docs/${user.id}/contract.pdf`, pdfFile, {
contentType: 'application/pdf',
});
// Read it back later (requires the user token)
const { data: blob, error: dlErr } = await lingcode.storage
.from('private')
.download(`docs/${user.id}/contract.pdf`);
if (!dlErr) {
const objectUrl = URL.createObjectURL(blob);
window.open(objectUrl); // open the PDF in a new tab
}
Store the path on a database row the user owns and let RLS gate who can see that row โ that's how you turn "who can read this file" into the same ownership check you already use for data.
You don't do anything special for big files. The SDK sees the size and switches to the presigned path on its own.
const video = document.querySelector('#video-input').files[0]; // e.g. 1.4 GB
// Same call. The SDK uploads this directly to object storage via a presigned PUT.
const { data, error } = await lingcode.storage
.from('private')
.upload(`recordings/${user.id}/${Date.now()}.mp4`, video, {
contentType: 'video/mp4',
});
const { data, error } = await lingcode.storage
.from('public')
.remove(`avatars/${user.id}.png`);
// data = { removed: true }
The SDK is a thin wrapper over a handful of HTTP endpoints. You'll only reach for these if you're working outside JavaScript or want to control the large-upload steps yourself. The base is https://lingcode.dev/api/cloud/be/<backend-id>. Authenticate with the anon key (as a Bearer token or ?apikey=) or the user token; bucket defaults if omitted. Object keys are namespaced per backend and bucket, and ./.. path traversal is stripped.
POST /storage/upload
Authorization: Bearer <anon-or-user-token>
Content-Type: application/json
{ "bucket": "public", "path": "avatars/me.png",
"content_type": "image/png", "data_b64": "<base64-bytes>" }
-> { "bucket": "public", "path": "avatars/me.png", "bytes": 40213, "url": "https://..." }
GET /storage/object?bucket=public&path=avatars/me.png
Authorization: Bearer <anon-or-user-token>
-> the file bytes (302-redirects to the CDN URL for public objects)
POST /storage/remove
{ "bucket": "public", "path": "avatars/me.png" }
-> { "removed": true }
This is what the SDK does automatically for files past the inline cap. First, ask for a presigned upload URL:
POST /storage/create-upload-url
{ "bucket": "private", "path": "recordings/big.mp4",
"content_type": "video/mp4" }
-> { "uploadUrl": "https://...", "method": "PUT",
"headers": { ... }, "bucket": "private", "path": "recordings/big.mp4" }
Second, PUT the bytes straight at uploadUrl using the returned method and headers โ these bytes go to object storage, not to your app server:
PUT <uploadUrl>
<headers from the previous response>
<raw file bytes>
Third, finalize to record the object so it counts against your backend and becomes readable:
POST /storage/finalize
{ "bucket": "private", "path": "recordings/big.mp4" }
-> { "bucket": "private", "path": "recordings/big.mp4", "bytes": 1490233856, "url": "https://..." }
If your object is under the inline per-object cap for your tier (maxObjectBytes โ 1 / 5 / 10 MB), one POST /storage/upload is all you need. Anything bigger uses the three-step presigned flow and is bounded by maxUploadBytes (50 MB / 1 GB / 5 GB) and the 5 GB single-PUT ceiling. The SDK's upload() picks for you; you only juggle the steps when calling REST directly.