File Uploads
Schema constraints, upload hooks, form fields, and storage differences between backends.
Schema
Define file fields with optional constraints:
import { file, files, schema } from 'noboil/convex/schema' // or noboil/spacetimedb/schema
import { object, string } from 'zod/v4'
const s = schema({
owned: {
post: object({
title: string().min(1),
cover: file({ accept: 'image/*', maxSize: 5 * 1024 * 1024 }).nullable().optional(),
attachments: files({ accept: 'image/*,application/pdf', maxSize: 10 * 1024 * 1024 }).optional()
})
}
})accept and maxSize flow through to form fields automatically — no need to pass them as props.
Upload hook
const upload = useUpload()
const handleFile = async (f: File) => {
const result = await upload(f)
// result.storageId — use in mutations
}Form fields
File constraints from the schema auto-apply:
<Form form={form} render={f => (
<>
<f.File name='cover' />
<f.Files name='attachments' />
<f.Submit>Save</f.Submit>
</>
)} />Or with AutoForm — fields auto-render with correct accept/maxSize:
<AutoForm form={form} submitLabel='Save' />Override schema constraints with explicit props when needed:
<f.File name='cover' accept='image/png' maxSize={2 * 1024 * 1024} />Storage differences
Files upload to Convex Storage (S3-compatible). Enriched documents automatically include {field}Url with a CDN URL:
const blog = await api.blog.read({ id })
blog.coverUrl // "https://cdn.convex.cloud/..."Files upload via HTTP and are served from the SpacetimeDB module. Enriched documents include {field}Url with an HTTP URL:
const blog = useTable(tables.blog)
blog.coverUrl // "http://localhost:4200/..."Both backends resolve file references to URLs — the only difference is CDN vs direct HTTP, which is a deployment concern.