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
FormDatato a mutation procedure, the server parses it and passes all form fields (includingFileobjects) asinput.
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-Typeheader 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:
- 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 throughctx.storage.listMeta(). - Product data (managed by your app) — if your app has a
filestable 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);limitis clamped to[1, 100](default20).- Cursors are opaque tokens — treat them as strings. Reusing a cursor with a different
order(e.g. desc cursor on an asc call) returnsInvalid 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,
listMetathrowsStorageListUnsupportedError. Plain local-devgencow dev --localincludes 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% smallerSmart 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 imageThese 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 transformsTransform 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 —
withoutEnlargementis 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 cacheWhen 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
- Cron Jobs — Scheduled tasks
- Deployment — Cloud deployment