Assets

Centralized asset management split across three built-in entities by content type.

Assets are stored across three built-in entities — _image, _video, and _file — each with only the fields relevant to its type. All uploads go through a single endpoint and are routed automatically by MIME type.

Built-in entities

_image

| Field | Type | Required | Notes | |-------|------|----------|-------| | id | auto | yes | UUID primary key | | filename | text | yes | Original filename | | mimeType | text | yes | e.g. image/jpeg, image/png | | size | number | no | File size in bytes | | width | number | no | Extracted via sharp | | height | number | no | Extracted via sharp | | alt | text | no | Alt text for accessibility | | url | text | yes | Storage URL |

_video

| Field | Type | Required | Notes | |-------|------|----------|-------| | id | auto | yes | UUID primary key | | filename | text | yes | Original filename | | mimeType | text | yes | e.g. video/mp4, video/webm | | size | number | no | File size in bytes | | width | number | no | Reserved for future metadata extraction | | height | number | no | Reserved for future metadata extraction | | url | text | yes | Storage URL |

_file

| Field | Type | Required | Notes | |-------|------|----------|-------| | id | auto | yes | UUID primary key | | filename | text | yes | Original filename | | mimeType | text | yes | e.g. application/pdf | | size | number | no | File size in bytes | | url | text | yes | Storage URL |

Uploading files

All uploads go to a single endpoint. The file is routed to the correct entity based on its MIME type.


          POST /api/assets/upload
Content-Type: multipart/form-data

file: <binary>
alt: "Description of the image"  (optional, only stored for images)
        

MIME type routing

| MIME pattern | Entity | |---|---| | image/* | _image | | video/* | _video | | Everything else | _file |

The mapping is by MIME type, not by behavior — SVGs and animated GIFs are treated as images.

Response — image


          {
  "data": {
    "id": "uuid",
    "filename": "photo.jpg",
    "mimeType": "image/jpeg",
    "size": 245000,
    "width": 1920,
    "height": 1080,
    "alt": "Description of the image",
    "url": "/api/assets/abc123.jpg",
    "createdAt": "2025-06-01T00:00:00Z",
    "updatedAt": "2025-06-01T00:00:00Z"
  }
}
        

Image dimensions are extracted automatically via sharp for JPEG, PNG, WebP, AVIF, TIFF, and GIF. SVGs and unreadable images will have null dimensions.

Response — video


          {
  "data": {
    "id": "uuid",
    "filename": "intro.mp4",
    "mimeType": "video/mp4",
    "size": 8200000,
    "url": "/api/assets/def456.mp4",
    "createdAt": "2025-06-01T00:00:00Z",
    "updatedAt": "2025-06-01T00:00:00Z"
  }
}
        

Response — file


          {
  "data": {
    "id": "uuid",
    "filename": "report.pdf",
    "mimeType": "application/pdf",
    "size": 120000,
    "url": "/api/assets/ghi789.pdf",
    "createdAt": "2025-06-01T00:00:00Z",
    "updatedAt": "2025-06-01T00:00:00Z"
  }
}
        

Image transforms

Uploaded images can be transformed on-the-fly by adding query parameters to the file URL:


          GET /api/assets/abc123.jpg?w=400&format=webp
GET /api/assets/abc123.jpg?w=800&fit=cover&format=avif&q=75
        

Parameters

| Param | Description | Default | |-------|-------------|---------| | w | Width in pixels (snapped to nearest allowed breakpoint). Height is calculated from aspect ratio. | Original | | fit | Resize strategy: cover, contain, fill, inside, outside | cover | | format | Output format: webp, avif, jpeg, png | webp | | q | Quality (1–100) | 80 |

Width is snapped to the nearest standard breakpoint (16, 32, 48, 64, 96, 128, 256, 384, 640, 750, 828, 1080, 1200, 1920, 2048, 3840) to prevent cache busting from arbitrary values. Images are never enlarged beyond their original dimensions.

All responses (originals and transforms) are served with Cache-Control: public, max-age=31536000, immutable. Put a CDN in front and transforms are cached at the edge after the first request — no server-side caching needed.

Without any query parameters, the original file is served as-is.

Transforms apply to images only (_image). Video and file URLs are served as-is.

Deduplication

Files are deduplicated by SHA-256 hash, checked within the same entity. Uploading the same image twice returns the existing _image record instead of creating a duplicate. If you upload a duplicate with a different alt, the alt text is updated on the existing record.

Deduplication is scoped to the target entity — the same binary uploaded as an image and then as a file would create two separate records.

Referencing assets

Use the typed field helpers in your entity config:


          import { entity, text, image, video, file } from "@cms/config";

const posts = entity("posts", {
  fields: [
    text("title", { required: true }),
    image("featuredImage"),
    image("gallery", { multiple: true }),
    video("demoVideo"),
    file("attachment"),
    file("downloads", { multiple: true }),
  ],
});
        

For fields that accept multiple asset types, use a polymorphic relation directly:


          import { relation } from "@cms/config";

relation("media", { to: ["_image", "_video"] })
        

In the admin UI, image(), video(), and file() fields automatically render typed upload inputs with preview, browse, and remove controls.

Storage adapters

Asset storage is configured in cms.config.ts. The adapter is "local" by default.

Local storage

Files are saved to ./uploads/ and served via the CMS API. No extra configuration needed.


          export default config({
  storage: { adapter: "local" },
  // ...
});
        

S3

Store assets in any S3-compatible service — AWS S3, MinIO, Cloudflare R2, etc.


          export default config({
  storage: {
    adapter: "s3",
    bucket: process.env.S3_BUCKET!,
    region: process.env.S3_REGION!,
    accessKeyId: process.env.S3_ACCESS_KEY_ID!,
    secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!,
    // Optional: custom endpoint for non-AWS services (MinIO, R2, etc.)
    endpoint: process.env.S3_ENDPOINT,
  },
  // ...
});
        

| Field | Required | Description | |---|---|---| | bucket | yes | S3 bucket name | | region | yes | AWS region, e.g. us-east-1 | | accessKeyId | yes | Access key ID | | secretAccessKey | yes | Secret access key | | endpoint | no | Custom endpoint URL for S3-compatible services |

All asset URLs are served through the CMS API (/api/assets/{key}) regardless of the storage adapter. The CMS fetches originals from S3 on demand and handles all transforms. Responses are served with Cache-Control: public, max-age=31536000, immutable — put a CDN (CloudFront, Cloudflare, etc.) in front and it will cache at the edge after the first request.

Querying assets

Each asset type is a regular entity and can be queried like any other:


          GET /api/_image?filter[mimeType][$startsWith]=image/png&sort=-createdAt&limit=20
GET /api/_video?sort=-createdAt
GET /api/_file?filter[mimeType]=application/pdf
        

Resolved asset relations return full records:


          GET /api/posts?resolve[featuredImage]=*
        

Previous

Drafts and publishing

Next

Internationalization