Skip to content

Storage and Uploads

timbl stores uploaded files through a StorageAdapter. The default writes to a local directory and serves files through a reverse proxy or the storage adapter’s url method. This page covers the default and how to replace it.

The default storage adapter

The default adapter writes files to a local directory on disk and returns a URL pointing to where your reverse proxy or static file server can serve them. It implements the three StorageAdapter methods:

  • upload(input): writes the File to disk, returns a StoredFile with filename, originalName, mimeType, size, path, and url.
  • delete(path): removes the file from disk.
  • url(path): returns the public URL for a stored file.

You configure the base directory and the URL prefix through runtime options. The runtime passes upload requests through POST /api/assets (see HTTP API Reference).

Upload field

Add an upload field to any collection to attach files. Set many to true for a gallery, restrict types with allowedMimeTypes, and cap size with maxSize.

{ key: "featuredImage", type: "upload" }
{ key: "gallery", type: "upload", many: true, allowedMimeTypes: ["image/png", "image/jpeg"], maxSize: 5242880 }

The runtime validates the file type and size before handing the upload to the storage adapter. See Field Types for the full upload options.

Uploading via the API

Uploads use multipart/form-data. The file field is required. Optional fields: directory, filename, alt.

Terminal window
curl -X POST http://localhost:3000/api/assets \
-H "Cookie: ..." \
-F "file=@photo.jpg" \
-F "directory=posts"

The response is the stored asset record. Reads (GET /api/assets, GET /api/assets/:id) are public. Deletes (DELETE /api/assets/:id) require a session.

Serving files

The default headless stack does not serve files from /uploads/* itself. That path is reserved for your reverse proxy or static file server. Configure your proxy (Caddy, Nginx, Traefik) to serve the storage directory at the URL prefix the adapter returns. See Deployment for reverse proxy configs that handle this.

Custom storage adapter

Replace the default with your own by implementing StorageAdapter and passing it into defineCMS.

import { defineCMS } from "timbl";
import type { StorageAdapter, StorageUploadInput, StoredFile } from "@timbl/core";
const createS3StorageAdapter = (options: { bucket: string; region: string }): StorageAdapter => {
return {
async upload(input: StorageUploadInput): Promise<StoredFile> {
// Upload to S3, return StoredFile with the public URL
},
async delete(path: string): Promise<void> {
// Delete from S3
},
url(path: string): string {
return `https://${options.bucket}.s3.${options.region}.amazonaws.com/${path}`;
},
};
};
export default defineCMS({
collections: [/* ... */],
adapters: {
storage: createS3StorageAdapter({ bucket: "my-assets", region: "us-east-1" }),
},
});

StoredFile requires six fields: filename, originalName, mimeType, size, path, and url. The runtime validates your implementation against StorageAdapterMethodSchemas at startup, so a missing or mis-typed method fails fast.

See Custom Adapters for the full interface and the validation contract.

See also