Docs / Search / LingCode Cloud / Storage
๐Ÿ“˜ Reference โ— Files Updated 2026-06-11

Storage

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.

Why two buckets

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:

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.

Public vs. private โ€” the one-line rule

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.

The SDK surface

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');

The upload story: small vs. large

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.

Why presigned uploads matter

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.

Per-tier limits

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.

A 402 is a quota signal, not a bug. If 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.

Worked examples

Upload an avatar to public and render it

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`);

Upload a private document

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.

Upload large media โ€” just call upload()

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',
  });

Remove a file

const { data, error } = await lingcode.storage
  .from('public')
  .remove(`avatars/${user.id}.png`);

// data = { removed: true }

Under the hood (REST)

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.

Inline upload (small files)

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://..." }

Read an object

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)

Remove an object

POST /storage/remove

{ "bucket": "public", "path": "avatars/me.png" }

-> { "removed": true }

Direct-to-storage upload (large files), in three steps

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://..." }

Which path will my upload take?

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.