onvibe.run

← All docs

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:


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


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.

Note: icons are cached by browsers (~10 min), so a logo change may take a few minutes to show.

Read this page as Markdown (best for LLMs) · plain text
onvibe.run · home · all docs