diff --git a/packages/admin/src/components/MediaPickerModal.tsx b/packages/admin/src/components/MediaPickerModal.tsx
index ac26797d09..559657f859 100644
--- a/packages/admin/src/components/MediaPickerModal.tsx
+++ b/packages/admin/src/components/MediaPickerModal.tsx
@@ -27,7 +27,12 @@ import {
type MediaProviderItem,
} from "../lib/api";
import { useDebouncedValue } from "../lib/hooks.js";
-import { providerItemToMediaItem, getFileIcon } from "../lib/media-utils";
+import {
+ providerItemToMediaItem,
+ getFileIcon,
+ getMediaThumbnailUrl,
+ fallbackToOriginalThumbnail,
+} from "../lib/media-utils";
import { matchesMimeAllowlist, mimeFromUrl } from "../lib/mime-utils.js";
import { cn } from "../lib/utils";
import { DialogError } from "./DialogError.js";
@@ -766,6 +771,12 @@ function MediaPickerItem({
const isImage = item.mimeType.startsWith("image/");
const needsDimensions = isImage && (!item.width || !item.height);
+ // Serve a resized thumbnail only when the original dimensions are already
+ // known. When they're missing we display the original so `onLoad` can read
+ // the true `naturalWidth`/`naturalHeight` to backfill them — a resized
+ // rendition would report the thumbnail's dimensions and corrupt the record.
+ const displayUrl = needsDimensions ? item.url : getMediaThumbnailUrl(item.url, item.mimeType);
+
const handleImageLoad = React.useCallback(
(e: React.SyntheticEvent
) => {
if (needsDimensions && onDimensionsDetected) {
@@ -793,10 +804,11 @@ function MediaPickerItem({
>
{isImage ? (
fallbackToOriginalThumbnail(e.currentTarget, item.url)}
/>
) : (
diff --git a/packages/admin/src/lib/media-utils.ts b/packages/admin/src/lib/media-utils.ts
index f2d7e1c0c6..cf3d3e933d 100644
--- a/packages/admin/src/lib/media-utils.ts
+++ b/packages/admin/src/lib/media-utils.ts
@@ -19,6 +19,75 @@ export function providerItemToMediaItem(
} as MediaItem & { provider: string; meta?: Record };
}
+/** Root-absolute path prefix for locally stored media served by EmDash. */
+const INTERNAL_MEDIA_PREFIX = "/_emdash/api/media/file/";
+
+/**
+ * Default rendered width (CSS px) for admin grid thumbnails, requested at ~2x
+ * the largest grid cell (200px) so they stay crisp on HiDPI displays.
+ */
+export const MEDIA_THUMBNAIL_WIDTH = 400;
+
+/**
+ * Build a display URL for a media thumbnail in the admin grid/list views.
+ *
+ * Large libraries were slow to browse and search because every grid cell loaded
+ * the full-size original through the media proxy (#1488). This routes
+ * same-origin raster images through Astro's runtime image endpoint (`/_image`)
+ * to request a small resized rendition instead.
+ *
+ * Where a runtime image service transforms — sharp on Node, or the Cloudflare
+ * Images binding on Workers (the `@astrojs/cloudflare` v13 default) — the grid
+ * gets a lightweight thumbnail. Where none does (a `passthrough` config, or
+ * behind Cloudflare Access where the endpoint's same-origin source fetch is
+ * blocked) `/_image` streams the original, so this never renders worse than
+ * before. Callers should still fall back to the original on image `error` for
+ * the rare case where the endpoint rejects the request (e.g. a site whose
+ * configured origin differs from the admin's).
+ *
+ * Returns the URL unchanged for non-raster media (an icon renders instead),
+ * SVGs (vector — nothing to downscale, and some services reject them), and
+ * anything not served from the local media route (external/provider URLs are
+ * already remote renditions, not same-origin originals).
+ */
+export function getMediaThumbnailUrl(
+ originalUrl: string,
+ mimeType: string,
+ width: number = MEDIA_THUMBNAIL_WIDTH,
+): string {
+ if (!mimeType.startsWith("image/") || mimeType === "image/svg+xml") return originalUrl;
+ if (!originalUrl.startsWith(INTERNAL_MEDIA_PREFIX)) return originalUrl;
+
+ // Astro authorizes the media route by absolute origin (see the
+ // `image.remotePatterns` entry the EmDash integration registers), so the
+ // transform source must be an absolute same-origin URL. The admin is served
+ // from the site origin, so `window.location.origin` is the right host.
+ const origin = typeof window === "undefined" ? "" : window.location.origin;
+ if (!origin) return originalUrl;
+
+ const params = new URLSearchParams({
+ href: `${origin}${originalUrl}`,
+ w: String(width),
+ f: "webp",
+ });
+ return `/_image?${params.toString()}`;
+}
+
+/**
+ * `onError` fallback for grid thumbnails: if a `/_image` rendition fails to
+ * load (e.g. the endpoint rejects the request on a site whose configured origin
+ * differs from the admin's), swap in the original URL once. Guarded with a data
+ * attribute so a failing original can't trigger a reload loop.
+ */
+export function fallbackToOriginalThumbnail(
+ img: { dataset: DOMStringMap; src: string },
+ originalUrl: string,
+): void {
+ if (img.dataset.thumbFallback) return;
+ img.dataset.thumbFallback = "1";
+ img.src = originalUrl;
+}
+
export function getFileIcon(mimeType: string): string {
if (mimeType.startsWith("video/")) return "🎬";
if (mimeType.startsWith("audio/")) return "🎵";
diff --git a/packages/admin/tests/lib/media-thumbnail.test.ts b/packages/admin/tests/lib/media-thumbnail.test.ts
new file mode 100644
index 0000000000..c34211f910
--- /dev/null
+++ b/packages/admin/tests/lib/media-thumbnail.test.ts
@@ -0,0 +1,59 @@
+import { describe, it, expect } from "vitest";
+
+import {
+ getMediaThumbnailUrl,
+ fallbackToOriginalThumbnail,
+ MEDIA_THUMBNAIL_WIDTH,
+} from "../../src/lib/media-utils";
+
+const LOCAL_IMAGE = "/_emdash/api/media/file/01ABC.jpg";
+
+describe("getMediaThumbnailUrl", () => {
+ it("routes a local raster image through Astro's /_image endpoint", () => {
+ const result = getMediaThumbnailUrl(LOCAL_IMAGE, "image/jpeg");
+ expect(result.startsWith("/_image?")).toBe(true);
+
+ const url = new URL(result, window.location.origin);
+ expect(url.pathname).toBe("/_image");
+ expect(url.searchParams.get("href")).toBe(`${window.location.origin}${LOCAL_IMAGE}`);
+ expect(url.searchParams.get("w")).toBe(String(MEDIA_THUMBNAIL_WIDTH));
+ expect(url.searchParams.get("f")).toBe("webp");
+ });
+
+ it("honors a custom width", () => {
+ const result = getMediaThumbnailUrl(LOCAL_IMAGE, "image/png", 80);
+ const url = new URL(result, window.location.origin);
+ expect(url.searchParams.get("w")).toBe("80");
+ });
+
+ it("passes SVGs through unchanged (vector, nothing to downscale)", () => {
+ const svg = "/_emdash/api/media/file/01ABC.svg";
+ expect(getMediaThumbnailUrl(svg, "image/svg+xml")).toBe(svg);
+ });
+
+ it("passes non-image media through unchanged (an icon renders instead)", () => {
+ const pdf = "/_emdash/api/media/file/01ABC.pdf";
+ expect(getMediaThumbnailUrl(pdf, "application/pdf")).toBe(pdf);
+ });
+
+ it("passes external/provider URLs through unchanged (already a remote rendition)", () => {
+ const external = "https://images.example.com/photo.jpg";
+ expect(getMediaThumbnailUrl(external, "image/jpeg")).toBe(external);
+ });
+});
+
+describe("fallbackToOriginalThumbnail", () => {
+ it("swaps in the original URL on first error", () => {
+ const img = { dataset: {} as DOMStringMap, src: "/_image?href=...&w=400&f=webp" };
+ fallbackToOriginalThumbnail(img, LOCAL_IMAGE);
+ expect(img.src).toBe(LOCAL_IMAGE);
+ expect(img.dataset.thumbFallback).toBe("1");
+ });
+
+ it("does not loop if the original also fails", () => {
+ const img = { dataset: { thumbFallback: "1" } as DOMStringMap, src: LOCAL_IMAGE };
+ fallbackToOriginalThumbnail(img, "/some/other/url.jpg");
+ // Guard short-circuits: src is left untouched.
+ expect(img.src).toBe(LOCAL_IMAGE);
+ });
+});