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