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 <img src> |
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 <img src>. 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.runis a read-only CDN. It only serves files that were put there by theupload_assetMCP tool. There is no upload endpoint on it. To accept a file from a user, calluploadFile()from your server handler — neverfetch("https://assets.onvibe.run/...", { method: "POST" }). - Using
location.originin server-side logic.locationis a browser global; it does not exist in your Deno handler. Build absolute URLs fromnew URL(req.url)orDeno.env.get("APP_URL")instead. - Storing the signed URL in your database.
getDownloadUrlURLs expire (10 min by default). For PRIVATE uploads store the key returned byuploadFileand mint a fresh signed URL each render. (Public-uploadurls and static-asset CDN URLs are permanent and can be stored — in fact you SHOULD store the publicurland reuse it.) - Proxying public images through your handler. If you used
{ public: true }, the returnedurlis already a permanent, cacheable, public URL — put it in<img src>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<Response> {
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) => `<img src="${await getDownloadUrl(r.image_key)}">`),
);
return new Response(
`<!DOCTYPE html><form method="POST" action="/upload" enctype="multipart/form-data">
<input type="file" name="photo" accept="image/*" required>
<button>Upload</button>
</form>${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 anArrayBuffer,Uint8Array, or string.opts.public: truestores the file publicly and includes a permanenturlin the result.getDownloadUrl(key, expiresInSeconds?)defaults to 600s (10 min), capped at 7 days. Only needed for PRIVATE uploads — public uploads already have a permanenturl.- Size limits:
uploadFileis capped at 2 MB per file (it proxies the bytes through the platform). For larger files usecreateDirectUpload(browser→storage direct), up to 10 MB. Both honor a 200 MB quota per project (public + private share the quota). createDirectUploadalso takes{ public: true }— same public/private choice asuploadFile, for files up to 10 MB.confirmDirectUploadthen returns the permanenturl.- Both helpers authenticate via
APP_TOKEN/APP_IDinjected 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<Response> {
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) => `<img src="${r.image_url}">`); // permanent URL, no getDownloadUrl
return new Response(
`<form method="POST" action="/upload" enctype="multipart/form-data">
<input type="file" name="photo" accept="image/*" required><button>Upload</button>
</form>${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: "<base64>", encoding: "base64" })
// -> { url: "https://assets.onvibe.run/my-app/images/logo.png" } ← permanent, public
Then reference the returned URL directly in HTML: <img src="https://assets.onvibe.run/my-app/images/logo.png">.
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: "<base64>", format: "png" | "svg" }) - HTTP (token-saving, no base64):
curl -X POST {BASE_URL}/api/projects/{id}/logo -H 'Authorization: Bearer <api_key>' -F 'logo=@./logo.png'
- MCP:
- 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.icoand/apple-touch-icon.pngby convention, so those work with zero markup. For full PWA install support, add the usual tags to your HTML<head>(onvibe serves the targets, you reference them):<link rel="icon" href="/favicon.svg">,<link rel="apple-touch-icon" href="/apple-touch-icon.png">,<link rel="manifest" href="/site.webmanifest">.
Note: icons are cached by browsers (~10 min), so a logo change may take a few minutes to show.