File Uploads & Static Assets
onvibe has two completely separate storage systems. Picking the wrong one is the
single most common mistake when building an upload feature. Read this table first.
Private end-user uploads Public end-user uploads Static assets
Who uploads Your app's users, at runtime Your app's users, at runtime You (the developer), at build time
API uploadFile(...) / getDownloadUrl uploadFile(..., { public: true }) upload_asset MCP tool
Visibility Private — returns a *key* Public — returns a permanent url Public — permanent CDN URL
Serving Mint a short-lived signed URL each render Use the returned url directly in Reference the URL directly in HTML
Caching / proxy Not cacheable; never proxy Cacheable, no proxy needed Cacheable
Access control Only via signed URL, revocable Anyone with the (unguessable) link Public
Use for Sensitive/owner-only files Avatars, covers, product images shown in the UI Your logo, CSS, fonts, app icons
Default to public for anything you display in the UI (avatars, covers, galleries):
uploadFile(bytes, type, name, { public: true }) returns a permanent, cacheable url you
drop straight into . No getDownloadUrl, no re-signing per render, and **no need
to proxy the bytes through your app**. Keep the private default only for sensitive,
access-controlled files (documents, anything that must stay owner-only): a public link is an
unguessable "capability URL", so anyone it's shared with — or who finds it in a log/referer —
can read it until the file is deleted.
---
⛔ Anti-patterns (these do NOT work)
These are real mistakes people hit. None of them work — do not try them:
- POSTing to https://assets.onvibe.run/... from your app code. assets.onvibe.run
is a read-only CDN. It only serves files that were put there by the upload_asset
MCP tool. There is no upload endpoint on it. To accept a file from a user, call
uploadFile() from your server handler — never fetch("https://assets.onvibe.run/...", { method: "POST" }).
- Using location.origin in server-side logic. location is a *browser* global; it
does not exist in your Deno handler. Build absolute URLs from new URL(req.url) or
Deno.env.get("APP_URL") instead.
- Storing the signed URL in your database. getDownloadUrl URLs expire (10 min by
default). For PRIVATE uploads store the key returned by uploadFile and mint a fresh
signed URL each render. (Public-upload urls and static-asset CDN URLs are permanent and
*can* be stored — in fact you SHOULD store the public url and reuse it.)
- Proxying public images through your handler. If you used { public: true }, the
returned url is already a permanent, cacheable, public URL — put it in
as-is. Do not add a route that re-fetches and streams the bytes; that defeats caching.
---
End-user uploads — the correct full flow
import { withErrorReporting, uploadFile, getDownloadUrl } from "./.onvibe/helpers.ts";
import { Pool } from "npm:pg";
// ... pgConfig + pool as in onvibe://docs/database ...
async function handler(req: Request): Promise {
const url = new URL(req.url);
// 1. Accept the upload (multipart form with a file input named "photo").
if (req.method === "POST" && url.pathname === "/upload") {
const form = await req.formData();
const file = form.get("photo");
if (!(file instanceof File)) {
return new Response("no file", { status: 400 });
}
const { key } = await uploadFile(await file.arrayBuffer(), file.type, file.name);
// 2. Store the KEY (not a URL) in your DB.
await pool.query("INSERT INTO photos (image_key) VALUES ($1)", [key]);
return Response.redirect(new URL("/", req.url), 303);
}
// 3. At render time, mint a fresh signed URL from the stored key.
if (req.method === "GET" && url.pathname === "/") {
const { rows } = await pool.query<{ id: number; image_key: string }>(
"SELECT id, image_key FROM photos ORDER BY id DESC",
);
const imgs = await Promise.all(
rows.map(async (r) => ),
);
return new Response(
`${imgs.join("")}`,
{ headers: { "content-type": "text/html; charset=utf-8" } },
);
}
return new Response("Not found", { status: 404 });
}
export default withErrorReporting(handler);
Matching schema
Name the column for what you store — a key, not a URL — and keep it nullable until
a row actually has a file. See onvibe://docs/schema-migration for why.
CREATE TABLE IF NOT EXISTS photos (
id SERIAL PRIMARY KEY,
image_key TEXT, -- the key from uploadFile(); nullable
created_at TIMESTAMPTZ DEFAULT NOW()
);
Notes
- uploadFile(content, contentType, filename?, opts?) returns { key, uploadId, size, url? }.
Pass an ArrayBuffer, Uint8Array, or string. opts.public: true stores the file
publicly and includes a permanent url in the result.
- getDownloadUrl(key, expiresInSeconds?) defaults to 600s (10 min), capped at 7 days.
Only needed for PRIVATE uploads — public uploads already have a permanent url.
- Size limits: uploadFile is capped at 2 MB per file (it proxies the bytes through
the platform). For larger files use createDirectUpload (browser→storage direct), up to
10 MB. Both honor a 200 MB quota per project (public + private share the quota).
- createDirectUpload also takes { public: true } — same public/private choice as
uploadFile, for files up to 10 MB. confirmDirectUpload then returns the permanent url.
- Both helpers authenticate via APP_TOKEN / APP_ID injected at deploy time — no config.
---
Public end-user uploads — permanent cacheable URL, no proxy
When the file is shown in the UI and isn't sensitive, pass { public: true } and store the
returned url. It's permanent and cacheable, so you render it directly — no signed URLs, no
per-render work, no proxy route.
uploadFile caps public files at 2 MB. For larger public files (up to 10 MB) use
createDirectUpload({ ..., public: true }) — the browser uploads straight to storage and
confirmDirectUpload(name) returns the permanent url.
import { withErrorReporting, uploadFile } from "./.onvibe/helpers.ts";
// ... pgConfig + pool as in onvibe://docs/database ...
async function handler(req: Request): Promise {
const url = new URL(req.url);
if (req.method === "POST" && url.pathname === "/upload") {
const form = await req.formData();
const file = form.get("photo");
if (!(file instanceof File)) return new Response("no file", { status: 400 });
// Store the permanent public URL directly (not a key).
const { url: imageUrl } = await uploadFile(await file.arrayBuffer(), file.type, file.name, { public: true });
await pool.query("INSERT INTO photos (image_url) VALUES ($1)", [imageUrl]);
return Response.redirect(new URL("/", req.url), 303);
}
if (req.method === "GET" && url.pathname === "/") {
const { rows } = await pool.query<{ image_url: string }>("SELECT image_url FROM photos ORDER BY id DESC");
const imgs = rows.map((r) => ); // permanent URL, no getDownloadUrl
return new Response(
`${imgs.join("")}`,
{ headers: { "content-type": "text/html; charset=utf-8" } },
);
}
return new Response("Not found", { status: 404 });
}
export default withErrorReporting(handler);
---
Static assets — for developer-provided files
Use the upload_asset MCP tool (not app code) for files you ship with the app:
upload_asset({ project_id: "my-app", path: "images/logo.png", content: "", encoding: "base64" })
// -> { url: "https://assets.onvibe.run/my-app/images/logo.png" } ← permanent, public
Then reference the returned URL directly in HTML: .
These URLs never expire, so storing them is fine.
---
App icons & logo (favicon, PWA, apple-touch)
You do not need to hand-craft a favicon, PWA icons or a web manifest. onvibe serves a
complete icon set for every project automatically at these paths:
/favicon.ico, /favicon.svg, /apple-touch-icon.png, /icon-192.png, /icon-512.png,
/icon-maskable-192.png, /icon-maskable-512.png, /site.webmanifest.
- Default: until a logo is set, onvibe serves a neutral generated icon (the project's
initial on a stable colored background) for all of these. So a fresh app already has a
favicon — you don't have to do anything.
- Set a logo: provide ONE square image (PNG/JPG ≥512×512 recommended, or SVG) and onvibe
generates every size/format from it, including the maskable PWA variants and the manifest:
- MCP: set_logo({ project_id, content: "", format: "png" "svg" })
- HTTP (token-saving, no base64): `curl -X POST {BASE_URL}/api/projects/{id}/logo
-H 'Authorization: Bearer ' -F 'logo=@./logo.png'`
- Override: if your app serves one of those paths itself (e.g. your handler returns a
custom /favicon.ico), your app always wins — onvibe only fills in the ones your app 404s.
- Discovery: browsers request /favicon.ico and /apple-touch-icon.png by convention,
so those work with zero markup. For full PWA install support, add the usual tags to your
HTML (onvibe serves the targets, you reference them):
, ,
.
Note: icons are cached by browsers (~10 min), so a logo change may take a few minutes to show.