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

FieldTypeRequiredNotes
idautoyesUUID primary key
filenametextyesOriginal filename
mimeTypetextyese.g. image/jpeg, image/png
sizenumbernoFile size in bytes
widthnumbernoExtracted via sharp
heightnumbernoExtracted via sharp
alttextnoAlt text for accessibility
urltextyesStorage URL

_video

FieldTypeRequiredNotes
idautoyesUUID primary key
filenametextyesOriginal filename
mimeTypetextyese.g. video/mp4, video/webm
sizenumbernoFile size in bytes
widthnumbernoReserved for future metadata extraction
heightnumbernoReserved for future metadata extraction
urltextyesStorage URL

_file

FieldTypeRequiredNotes
idautoyesUUID primary key
filenametextyesOriginal filename
mimeTypetextyese.g. application/pdf
sizenumbernoFile size in bytes
urltextyesStorage 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 patternEntity
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

ParamDescriptionDefault
wWidth in pixels (snapped to nearest allowed breakpoint). Height is calculated from aspect ratio.Original
fitResize strategy: cover, contain, fill, inside, outsidecover
formatOutput format: webp, avif, jpeg, pngwebp
qQuality (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.

Public responses (originals and transforms) are served with Cache-Control: public, max-age=31536000, immutable plus a strong ETag, and Access-Control-Allow-Origin: * with no Vary: Origin — so a CDN caches a single variant per URL. Put a CDN in front and transforms are cached at the edge after the first request — no server-side caching needed. Conditional requests (If-None-Match) get a 304 without re-running the transform.

Private assets are served with Cache-Control: private, no-store — they never land in a shared cache.

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,
  },
  // ...
});
        
FieldRequiredDescription
bucketyesS3 bucket name
regionyesAWS region, e.g. us-east-1
accessKeyIdyesAccess key ID
secretAccessKeyyesSecret access key
endpointnoCustom 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. Public responses are served with Cache-Control: public, max-age=31536000, immutable and a strong ETag — put a CDN (CloudFront, Cloudflare, etc.) in front and it will cache at the edge after the first request. Private assets are served with Cache-Control: private, no-store and are never cached at the edge.

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