Storage
File upload, download, and management with ctx.storage
Gencow provides a built-in file storage system. Upload private attachments, publish website assets, create short-lived preview/download URLs, and manage storage through ctx.storage.
Storage is private by default in hosted runtimes. A bare /api/storage/<id> URL only works anonymously when the storage object is explicitly marked public. Private files need a short-lived read grant URL.
Uploading Files
From a Write Procedure (Backend)
import { v } from "@gencow/core";
import { procedure } from "./runtime";
export const upload = procedure.mutation
.name("files.upload")
.input(
v.object({
file: v.file(),
}),
)
.output(
v.object({
storageId: v.string(),
url: v.string(),
urlExpiresAt: v.dateTime(),
name: v.string(),
size: v.number(),
type: v.string(),
}),
)
.handler(async ({ context: ctx, input }) => {
// Store file -> private by default, returns a unique storageId
const storageId = await ctx.storage.store(input.file);
// Create a short-lived browser URL for private preview/download
const readGrant = await ctx.storage.createReadGrant(storageId, {
ttlSeconds: 300,
disposition: "inline",
});
const file = input.file;
return {
storageId,
url: readGrant.url,
urlExpiresAt: readGrant.expiresAt,
name: file instanceof File ? file.name : "upload.bin",
size: file.size,
type: file.type || "application/octet-stream",
};
});How it works: Declare file fields with
v.file()in.input(). The server validates and typesinput.fileas aFileorBlob. On the client, pass{ file }touseMutation(multipart is handled automatically) or sendFormDatawith a matching field name.
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 grant = await ctx.storage.createReadGrant(storageId, { disposition: "attachment" });Public Website Assets
Use public visibility only for files that are intentionally readable by anyone who has the URL, such as hero images, logos, gallery images, certificates, and public downloads.
const storageId = await ctx.storage.store(file, {
visibility: "public",
});
const url = ctx.storage.getPublicUrl(storageId);
// -> "/api/storage/<uuid>"You can also publish generated files:
const storageId = await ctx.storage.storeBuffer(
imageBuffer,
"hero.png",
"image/png",
{ visibility: "public" },
);
const url = ctx.storage.getPublicUrl(storageId);Serving Files
ctx.storage.getUrl() returns the logical bare storage path:
const url = ctx.storage.getUrl(storageId);
// -> "/api/storage/<uuid>"
// Full URL: "http://localhost:5456/api/storage/<uuid>"In hosted runtimes, that bare URL is anonymous-readable only for public storage objects. Private files return 403 unless the URL includes a valid read grant:
const preview = await ctx.storage.createReadGrant(storageId, {
ttlSeconds: 300, // clamped to 1..300, default 60
disposition: "inline", // "inline" preview or "attachment" download
});
const download = await ctx.storage.createReadGrant(storageId, {
ttlSeconds: 300,
disposition: "attachment",
});| Use case | Recommended API |
|---|---|
| Public website image/download | store(file, { visibility: "public" }) + getPublicUrl() |
| Private attachment preview | createReadGrant(id, { disposition: "inline" }) |
| Private attachment download | createReadGrant(id, { disposition: "attachment" }) |
| Server-side processing | ctx.storage.get(id) |
Files are served with:
- Correct
Content-Typeheader - Public files:
Cache-Control: public, max-age=31536000, immutable - Private read grants:
Cache-Control: private, max-age=<remaining ttl>orprivate, no-store - UTF-8 filename support
Public inline rendering is restricted to safe image MIME types. Public text/html, JavaScript, SVG, and other scriptable content is served as an attachment even when the object is public.
Changing Visibility
await ctx.storage.setVisibility(storageId, "public");
await ctx.storage.setVisibility(storageId, "private");Changing a file from public to private closes fresh bare URL requests immediately. Existing browser/CDN caches may still retain bytes until their cache TTL expires.
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 result = await upload({ file });
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: f.visibility === "public"
? ctx.storage.getPublicUrl(f.id)
: null,
})),
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 { v } from "@gencow/core";
import { procedure } from "./runtime";
export const upload = procedure.mutation
.name("files.upload")
.input(
v.object({
file: v.file(),
}),
)
.handler(async ({ context: ctx, input }) => {
const file = input.file;
const storageId = await ctx.storage.store(file);
const readGrant = await ctx.storage.createReadGrant(storageId, {
ttlSeconds: 300,
disposition: "inline",
});
const [row] = await ctx.db.insert(files).values({
storageId,
userId: user.id,
name: file instanceof File ? file.name : "upload.bin",
}).returning();
return { ...row, url: readGrant.url };
});
export const list = procedure.query
.name("files.list")
.handler(async ({ context: ctx }) => {
const rows = await ctx.db
.select()
.from(files)
.where(eq(files.userId, user.id))
.orderBy(desc(files.createdAt));
return Promise.all(rows.map(async (r) => {
const readGrant = await ctx.storage.createReadGrant(r.storageId, {
ttlSeconds: 300,
disposition: "inline",
});
return { ...r, url: readGrant.url };
}));
});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 for public asset URLs and grant-backed private image URLs.
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.
For public objects 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. Private objects still require a valid read grant.
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)
Startup 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 (Startup & Enterprise)
On Startup and Enterprise plans, you can transform images on-the-fly using URL parameters. Public assets use the bare URL. Private assets use a read grant URL; append transform parameters with & after the grant parameter.
/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
/api/storage/<uuid>?grant=<token>&w=800 → Private image with read grantTransform 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) | Startup | Enterprise |
|---|---|---|---|
| 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"
}The API error message may still reference the legacy internal plan id during rollout. Hobby users need a Startup plan or higher for explicit resize/transform parameters.
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 | Startup | Enterprise |
|---|---|---|---|
| Max file size | 50 MB | 50 MB | 50 MB |
| Storage quota | 1 GB | 20 GB | Custom |
| 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, filenameOrOptions?) |
Store a File/Blob. Default visibility is private; pass { visibility: "public" } for public assets |
ctx.storage.storeBuffer(buffer, filename, type?, options?) |
Store from Buffer. Default visibility is private; pass { visibility: "public" } for public assets |
ctx.storage.getUrl(storageId) |
Get logical bare storage URL. Anonymous-readable only when the object is public |
ctx.storage.getPublicUrl(storageId) |
Get bare URL for an intentionally public object |
ctx.storage.createReadGrant(storageId, { ttlSeconds?, disposition? }) |
Create short-lived private preview/download URL |
ctx.storage.getMeta(storageId) |
Get file metadata (id, name, size, type, visibility) |
| `ctx.storage.setVisibility(storageId, "public" | "private")` |
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