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.