Storage

File upload, download, and management with ctx.storage

Gencow provides a built-in file storage system. Upload files, get public URLs, and manage storage — all through ctx.storage.

Uploading Files

From a Write Procedure (Backend)

import { procedure } from "./runtime";

export const upload = procedure.mutation
    .name("files.upload")
    .handler(async ({ context: ctx, input }) => {
        ctx.auth.requireAuth();

        const file = input?.["file"] as File;
        if (!file || typeof file === "string") {
            throw new Error("No file provided");
        }

        // Store file → returns a unique storageId
        const storageId = await ctx.storage.store(file);

        // Get a public serving URL
        const url = ctx.storage.getUrl(storageId);
        // → "/api/storage/550e8400-e29b-41d4-a716-446655440000"

        return {
            storageId,
            url,
            name: file.name,
            size: file.size,
            type: file.type,
        };
    });

How it works: When the frontend sends FormData to a mutation procedure, the server parses it and passes all form fields (including File objects) as input.

Store from Buffer

// Useful for programmatic file creation (PDFs, exports, etc.)
const csvContent = "name,email\nJohn,[email protected]";
const buffer = Buffer.from(csvContent);

const storageId = await ctx.storage.storeBuffer(
    buffer,
    "export.csv",
    "text/csv"
);

const url = ctx.storage.getUrl(storageId);

Serving Files

ctx.storage.getUrl() returns a public URL that serves the file directly:

const url = ctx.storage.getUrl(storageId);
// → "/api/storage/<uuid>"
// Full URL: "http://localhost:5456/api/storage/<uuid>"

Files are served with:

  • Correct Content-Type header
  • Cache-Control: public, max-age=31536000, immutable
  • UTF-8 filename support

Note: File URLs are public (no auth required). Control access by only returning URLs to authorized users in your queries.

Deleting Files

import { procedure } from "./runtime";
import { v } from "@gencow/core";

export const remove = procedure.mutation
    .name("files.remove")
    .input(v.object({ storageId: v.string() }))
    .handler(async ({ context: ctx, input }) => {
        ctx.auth.requireAuth();
        await ctx.storage.delete(input.storageId);
    });

React File Upload

import { useMutation } from "@gencow/react";
import { api } from "../gencow/api";

function FileUpload() {
    const { mutate: upload, isPending } = useMutation(api.files.upload);

    const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
        const file = e.target.files?.[0];
        if (!file) return;

        const formData = new FormData();
        formData.append("file", file);

        const result = await upload(formData);
        console.log("Uploaded:", result.url);
    };

    return (
        <div>
            <input
                type="file"
                onChange={handleFileChange}
                disabled={isPending}
            />
            {isPending && <span>Uploading...</span>}
        </div>
    );
}

Listing Files

Gencow keeps two separate concerns:

  1. Runtime inventory (managed by Gencow) — every file you store is recorded in an internal catalog so quota accounting works and so you can recover from "I lost the storageId" situations. Read it through ctx.storage.listMeta().
  2. Product data (managed by your app) — if your app has a files table or any other table that ties files to users, projects, etc., that's your responsibility to maintain. ctx.storage.store() does not auto-populate that table.

Runtime inventory — ctx.storage.listMeta()

Use listMeta for admin/cleanup workflows, reconciliation jobs, or any time you need to enumerate stored objects without depending on an app-owned table:

import { procedure } from "./runtime";

export const adminList = procedure.query
    .name("admin.storage.list")
    .handler(async ({ context: ctx }) => {
        ctx.auth.requireAuth();

        const page = await ctx.storage.listMeta({
            limit: 20,
            order: "desc", // newest first; default
        });

        return {
            items: page.items.map(f => ({
                ...f,
                url: ctx.storage.getUrl(f.id),
            })),
            nextCursor: page.nextCursor,
        };
    });

To walk the whole catalog, pass nextCursor back in:

let cursor: string | null = null;
do {
    const page = await ctx.storage.listMeta({ limit: 100, cursor });
    for (const file of page.items) {
        // … process each file
    }
    cursor = page.nextCursor;
} while (cursor !== null);
  • limit is clamped to [1, 100] (default 20).
  • Cursors are opaque tokens — treat them as strings. Reusing a cursor with a different order (e.g. desc cursor on an asc call) returns Invalid cursor.
  • Returns metadata only (id, name, size, type) — no file buffers. Cheap for large catalogs.

Filesystem-only deployments: if the runtime has no SQL catalog wired up, listMeta throws StorageListUnsupportedError. Plain local-dev gencow dev --local includes the catalog, so this only affects custom adapters.

App-owned files table (optional)

If your app needs file rows linked to users or scoped by product rules, define and write to your own table in a mutation:

import { procedure } from "./runtime";

export const upload = procedure.mutation
    .name("files.upload")
    .handler(async ({ context: ctx, input }) => {
        const user = ctx.auth.requireAuth();
        const file = input?.["file"] as File;

        const storageId = await ctx.storage.store(file);

        const [row] = await ctx.db.insert(files).values({
            storageId,
            userId: user.id,
            name: file.name,
        }).returning();

        return { ...row, url: ctx.storage.getUrl(storageId) };
    });

export const list = procedure.query
    .name("files.list")
    .handler(async ({ context: ctx }) => {
        const user = ctx.auth.requireAuth();
        const rows = await ctx.db
            .select()
            .from(files)
            .where(eq(files.userId, user.id))
            .orderBy(desc(files.createdAt));
        return rows.map(r => ({ ...r, url: ctx.storage.getUrl(r.storageId) }));
    });

When to use which: product features and per-user authorization need an app table. Operational tooling (orphan-blob detection, quota dashboards, "list everything I uploaded") uses listMeta.

Image Optimization

Gencow automatically optimizes images served through storage. No configuration needed — it works out of the box.

Auto WebP

When a browser supports WebP (most modern browsers do), Gencow automatically converts your images to WebP format for smaller file sizes:

Browser requests: /api/storage/<uuid>
  → Accept: image/webp header detected
  → Image converted to WebP on-the-fly
  → Cached for subsequent requests

Result: PNG → WebP = up to 93% smaller
        JPEG → WebP = up to 57% smaller

Smart fallback: If the WebP version is larger than the original (rare, well-compressed JPEGs), the original is served instead. You never get a worse result.

This works even when your app is sleeping — images are served directly by the platform without waking your app, so there's zero cold start for image requests.

Custom Settings

You can customize Auto WebP behavior per app using the CLI:

# Set max width — images wider than this are downscaled
gencow config set image.maxWidth 1280

# Set quality (1-100, default: 75)
gencow config set image.quality 85

# Check current settings
gencow config get image

# Reset to tier defaults
gencow config reset image

These settings are clamped by your tier's maximum:

Hobby tier (max 1920px):
  Set 1280 → Applied: 1280 ✅ (within limit)
  Set 3840 → Applied: 1920 ✅ (clamped to tier max)

Pro tier (max 3840px):
  Set 1280 → Applied: 1280 ✅
  Set 3840 → Applied: 3840 ✅

Settings take effect immediately for new image requests. Previously cached images are unaffected (they use a different cache key).

Image Transforms (Pro & Scale)

On Pro and Scale plans, you can transform images on-the-fly using URL parameters:

/api/storage/<uuid>?w=800           → Resize to 800px wide
/api/storage/<uuid>?w=400&h=300     → Resize to 400×300
/api/storage/<uuid>?f=avif          → Convert to AVIF
/api/storage/<uuid>?q=60            → Set quality to 60%
/api/storage/<uuid>?w=800&f=webp&q=70  → Combine transforms

Transform Parameters

Parameter Description Values Example
w Width (px) 1–4096 ?w=800
h Height (px) 1–4096 ?h=600
f Output format webp, avif, jpeg, png ?f=webp
q Quality 1–100 (default: 80) ?q=70
fit Resize mode cover, contain, fill, inside ?fit=contain

Images are never upscaled — withoutEnlargement is always applied.

Tier Limits

Feature Hobby (Free) Pro Scale
Auto WebP ✅ Free
Auto downscale 1920px max 3840px max Unlimited
Resize (w, h) ❌ 403
Format convert (f) WebP only All formats All formats
Quality control (q) ❌ 403

Hobby plan users get automatic optimization (WebP + downscale to 1920px) for free. Requesting resize or quality parameters on Hobby returns:

{
    "error": "Image resize requires Pro plan",
    "code": "PLAN_LIMIT",
    "upgrade": "https://gencow.app/pricing"
}

Caching

Transformed images are cached on disk. The same URL always returns the cached version:

uploads/.cache/<uuid>_auto_webp_mw1920.webp   → Auto WebP cache (Hobby)
uploads/.cache/<uuid>_w800_fwebp_q80.webp     → Transform cache

When you delete a file with ctx.storage.delete(), all its cached transforms are automatically cleaned up.

Supported Image Types

Auto WebP and transforms work on:

  • JPEG / JPG
  • PNG
  • WebP (transforms only, Auto WebP skipped)

Non-image files (PDF, ZIP, etc.) and SVG/GIF are always served as-is.

Limits

Limit Hobby Pro Scale
Max file size 50 MB 50 MB 50 MB
Storage quota 1 GB 1 GB 1 GB
Image cache 200 MB 1 GB 5 GB
Supported types Any Any Any

Exceeding the quota throws: Storage quota exceeded: 950MB used + 100MB new = 1.05GB, quota is 1GB

Storage API Reference

Method Description
ctx.storage.store(file, filename?) Store a File/Blob → returns storageId
ctx.storage.storeBuffer(buffer, filename, type?) Store from Buffer → returns storageId
ctx.storage.getUrl(storageId) Get public serving URL
ctx.storage.getMeta(storageId) Get file metadata (name, size, type)
ctx.storage.listMeta({ limit?, cursor?, order? }) List runtime inventory with cursor pagination → { items, nextCursor }
ctx.storage.delete(storageId) Delete a file + all cached transforms

Next Steps