Skip to content

Assets

The admin includes a media library at /admin/assets with:

  • Grid view with thumbnails
  • Folder organization (create, rename, delete, drag-to-move)
  • Upload via button or drag-and-drop
  • Alt text editing
  • Focal point selector with live per-preset crop previews (click image to set crop center)

Image fields render an upload button and a browse dialog that connects to the media library. The stored value is the file URL (/uploads/<id>.<ext>, where <id> is a nanoid and <ext> comes from the verified MIME type). The original filename is not part of the path.

Asset metadata (filename, mime type, size, intrinsic width/height, alt, focal point) is stored in the cms_assets table. Width and height are captured from the file on upload (via Sharp) for raster images, so consumers can reserve layout space and avoid cumulative layout shift (CLS). File storage depends on the deployment target:

  • Node.js: public/uploads/ (or CMS_UPLOADS_DIR)
  • Cloudflare: R2, through the CMS_ASSETS binding

To store files elsewhere, replace the storage adapter; see Adapter contracts.

Method Endpoint Description
POST /api/cms/assets/upload Upload file (multipart/form-data)
GET /api/cms/assets List assets
GET /api/cms/assets/:id Get single asset
PATCH /api/cms/assets/:id Update metadata
DELETE /api/cms/assets/:id Delete asset and file
Param Type Description
limit number Max results (default 50)
offset number Skip N results
folder string Filter by folder ID (empty string for root)
q string Search by filename or alt text

Passing ?url=/uploads/<id>.<ext> instead returns the single asset matching that URL (404 if none).

Field Type Description
alt string Alt text
filename string Display filename
folder string | null Move to folder (null for root)
focalX number | null Focal point X (0–100)
focalY number | null Focal point Y (0–100)

For scripts and server code (importers, seeds), use assets.upload directly:

import { assets } from "@kidecms/core";
const asset = await assets.upload(new File([bytes], "photo.jpg", { type: "image/jpeg" }), {
alt: "A photo",
dedupe: true,
});
asset.storagePath; // "/uploads/<id>.jpg" — store this in image fields

With dedupe: true the upload is content-hashed: re-running the same upload returns the existing asset instead of writing a new file, so importers stay idempotent. See Migrations for the full import workflow.

Set a focal point (focalX/focalY, 0–100%) on any image in the asset detail view by clicking the spot that must stay in frame. The detail view shows live crop previews for each configured aspect ratio, so you can see how the image will be framed as a hero, card, square, etc., all from a single upload with no external editing.

The focal point is applied server-side whenever an image is cropped to an aspect ratio: the transform picks the largest source window matching the target ratio, positions it on the focal point (clamped so it never runs off-frame), then resizes. Media library thumbnails also reflect it.

If no focal point is set, crops use content-aware attention framing rather than a centered crop: Sharp’s strategy.attention locally, and gravity=auto on Cloudflare, so the crop keeps the most salient region in frame.

Images are resized and converted on demand by the endpoint /api/cms/img/[...path]: Sharp on Node, the Images binding on Cloudflare. Images inside rich text and content are served through it automatically with a responsive srcset, and take their alt text from the asset. For image fields, use <CmsImage>.

/api/cms/img/uploads/photo.jpg?w=800 → 800px wide WebP (no crop)
/api/cms/img/uploads/photo.jpg?w=1024&f=avif → 1024px wide AVIF
/api/cms/img/uploads/photo.jpg?w=1280&h=549 → cropped to 1280×549 (attention framing)
/api/cms/img/uploads/photo.jpg?w=1280&h=549&fx=50&fy=30 → cropped, framed on focal point
/api/cms/img/uploads/photo.jpg?q=90 → quality 90
Param Type Default Description
w number Width (snapped to nearest allowed size)
h number Height. When set with w, the image is cover-cropped to w×h
fx number Focal point X (0–100), used when cropping
fy number Focal point Y (0–100), used when cropping
f string webp Format: webp, avif, jpeg, png
q number 80 Quality (1–100)

With only w, the image keeps its aspect ratio. With w and h it is cover-cropped, framed on fx/fy when given (a missing axis defaults to 50) and on the most salient region otherwise. On Node, results are cached in .cms-cache/img/; every response has immutable cache headers.

On Cloudflare the Worker serves the same URLs through the Images binding and stores each rendition in the edge cache. Nothing to enable: it works in astro dev, on *.workers.dev, and on custom domains, and bills at Images pricing.

If you have enabled Image Transformations for your zone and would rather have renditions served entirely by Cloudflare’s edge (never invoking the Worker), opt in:

src/cms/cms.config.ts
images: { cloudflare: "cdn-cgi" },

Deployed pages then emit /cdn-cgi/image/… URLs. This needs a custom domain; astro dev keeps using the Worker route.

Presets are named renditions. Built-in presets: hero (21:9), heroMobile (4:5), banner (16:9), card (16:9), square (1:1), thumb (1:1), and content (no crop). Override or add to them in cms.config.ts:

export default defineConfig({
images: {
presets: {
// merged over the built-in defaults
banner: { aspect: "16/9", widths: [640, 960, 1280, 1920], formats: ["avif", "webp"], sizes: "100vw" },
product: { aspect: "4/3", widths: [320, 640, 960], formats: ["avif", "webp"], sizes: "50vw" },
},
},
collections: [
/* ... */
],
});

The images block is optional — omit it and the defaults still apply. A preset with no aspect resizes without cropping (preserving the source ratio).

<CmsImage> reads the asset record, so it applies the stored focal point, sets width/height (from the preset aspect or the asset’s dimensions) to prevent layout shift, emits AVIF and WebP sources, and renders nothing if the upload has been deleted.

---
import CmsImage from "@/components/CmsImage.astro";
---
<!-- The usual case -->
<CmsImage src={doc.image} alt={doc.title} preset="card" />
<!-- Art direction: 16:9 on desktop, recomposed to a 4:5 crop on mobile -->
<CmsImage
src={doc.image}
alt={doc.title}
preset="banner"
art={[{ media: "(max-width: 640px)", preset: "heroMobile" }]}
loading="eager"
fetchpriority="high"
/>
Prop Type Description
src string Asset URL (/uploads/…) or any external/static src
alt string Alt text
preset string Base rendition for the <img> and default sources (default banner)
art { media; preset }[] Art-directed sources — a different crop per breakpoint
class string Class applied to the <img>
focalX / focalY number Override the asset’s stored focal point
sizes string Override the base preset’s sizes
loading "eager" | "lazy" Native lazy-loading (default lazy)
fetchpriority "high" | "auto" | "low" Set high for the LCP hero

The art prop is optional — omit it and you get a single, focal-aware, multi-format rendition, which is what most images want.

For an og:image tag, a CSS background-image, JSON-LD, or an email template, these helpers return the same URLs as strings. They don’t read the asset record: no stored focal point, no dimensions, and no check that the upload still exists. Pass crop ({ aspect, focalX, focalY }) for a cropped rendition.

import { cmsImageUrl, cmsSrcset } from "@kidecms/core";
// Single optimized URL (resize only)
cmsImageUrl("/uploads/photo.jpg", 800);
// → /api/cms/img/uploads/photo.jpg?w=800
// Responsive srcset (resize only)
cmsSrcset("/uploads/photo.jpg", [480, 768, 1024]);
// → /api/cms/img/uploads/photo.jpg?w=480 480w, ...
// Cropped to a 21:9 hero, framed on a focal point you supply
cmsImageUrl("/uploads/photo.jpg", 1280, "webp", { aspect: "21/9", focalX: 50, focalY: 30 });
// → /api/cms/img/uploads/photo.jpg?w=1280&h=549&fx=50&fy=30

The height is derived from the (snapped) width and the aspect ratio, so the whole srcset stays at a consistent ratio. aspect accepts "21/9", "21:9", or "21x9".

Requested widths are snapped to the nearest allowed size to maximize cache efficiency: 320, 480, 640, 768, 960, 1024, 1280, 1536, 1920.