Technical documentation for the Galleries feature, implemented on 17/02/2026.
Reference: astro-paper issue #553
- Overview
- Files involved
- How to create a gallery
- Frontmatter: available fields
- How images are processed
- Automatic alt text
- Cover image (coverImage)
- Enable / disable and visibility flags
- Mixed feed integration (posts + galleries)
- GalleryEmbed — gallery inside MDX posts
- Architecture and data flow
- Lightbox
- Styles and responsive
- Known limitations
- Future extensions
The Galleries feature allows publishing image collections accessible at /galleries. Each gallery is a folder inside src/data/galleries/ containing:
- An
index.md(orindex.mdx) file with the gallery metadata. - The image files directly in that folder.
Images are processed by the Astro Assets pipeline (astro:assets) at build time, generating optimized versions with srcset, lazy loading, and automatic modern format conversion.
In addition to /galleries, gallery entries can participate in the global mixed feed (posts + galleries) used by /, /posts, /archives, /tags, and /rss.xml.
| File | Role |
|---|---|
src/config.ts |
showGalleries + showGalleriesInIndex flags |
src/content.config.ts |
galleries collection definition with Zod schema |
src/components/GalleryCard.astro |
Card used in the listing page |
src/components/GalleryEmbed.astro |
Component to embed galleries inside MDX posts |
src/components/Card.astro |
Shared card for blog/gallery mixed feeds (adds gallery badge) |
src/components/Header.astro |
Conditional nav link (desktop + mobile) |
src/assets/icons/IconGallery.svg |
Gallery icon used in header/cards/archive timeline |
src/pages/galleries/index.astro |
Listing page /galleries |
src/pages/galleries/[gallery].astro |
Detail page /galleries/<slug> |
src/pages/index.astro |
Optional mixed feed on homepage |
src/pages/posts/[...page].astro |
Optional mixed paginated listing |
src/pages/archives/index.astro |
Optional mixed archive timeline |
src/pages/tags/index.astro |
Optional mixed tag index |
src/pages/tags/[tag]/[...page].astro |
Optional mixed tag detail pagination |
src/pages/rss.xml.ts |
Optional mixed RSS feed aggregation |
src/layouts/PostDetails.astro |
Registers GalleryEmbed as a global MDX component |
src/utils/contentEntry.ts |
Shared typing + URL helpers for blog/gallery entries |
src/utils/getSortedPosts.ts |
Shared date sorting for mixed entries |
src/utils/getUniqueTags.ts |
Shared tag extraction for mixed entries |
src/utils/getPostsByTag.ts |
Shared tag filtering for mixed entries |
src/utils/getPostsByGroupCondition.ts |
Shared archive grouping helper for mixed entries |
src/data/galleries/ |
Root directory for gallery content |
src/data/galleries/
└── gallery-name/
├── index.md ← required metadata
├── 01-first.jpg
├── 02-second.jpg
└── 03-third.png
- The folder name becomes the URL slug:
gallery-name→/galleries/gallery-name - The metadata file must be named exactly
index.mdorindex.mdx. - Images are displayed sorted alphabetically by filename. Using a numeric prefix (
01-,02-, …) is the recommended way to control the order.
---
title: My Trip to Japan # required — visible gallery name
description: Travel photos... # required — shown in the card and <meta description>
pubDatetime: 2026-01-20T00:00:00Z # required — publication date (ISO 8601)
draft: false # optional — if true, not published (default: false)
coverImage: ./01-tokyo.jpg # optional — explicit cover image (see coverImage section)
tags: # optional — array of tags (no #)
- japan
- travel
---Note: unlike blog posts, galleries have no document body. All visual content comes from the images in the folder.
import.meta.glob with { eager: true } is used at build time:
const allImages = import.meta.glob<{ default: ImageMetadata }>(
"/src/data/galleries/**/*.{jpg,jpeg,png,webp,avif,gif,JPG,JPEG,PNG,WEBP}",
{ eager: true }
);Astro/Vite requires that globs for images processed by <Image /> be static and resolved at compile time. With eager: true, all modules are imported immediately and Vite generates the metadata (width, height, optimized src) for each image. Without eager, ImageMetadata would not be available to pass to the <Image /> component.
In [gallery].astro, the global glob is filtered using the URL slug:
const images = Object.entries(allImages)
.filter(([path]) =>
path.startsWith(`/src/data/galleries/${slug}/`) &&
!path.includes("index") // excludes index.md/mdx if it falls into the glob
)
.sort(([a], [b]) => a.localeCompare(b))
.map(([path, mod]) => ({ src: mod.default, alt: filenameToAlt(filename), filename }));The <Image /> component with these props:
<Image
src={img.src}
alt={img.alt}
widths={[400, 800]}
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
loading={idx < 6 ? "eager" : "lazy"}
/>Automatically generates:
srcsetwith versions at 400 px and 800 px.- Conversion to a modern format (WebP/AVIF depending on browser support).
- Explicit
widthandheightto prevent layout shift (CLS). - Lazy loading for images beyond the 6th (the first ones load
eagerfor above-the-fold).
When there is no explicit alt metadata, the alt text is derived from the filename:
01-sunset-in-kyoto.jpg → "Sunset In Kyoto"
002_fuji_mountain.png → "Fuji Mountain"
IMG_4532.JPG → "IMG 4532" (falls back to gallery title if empty)
Responsible function (filenameToAlt in [gallery].astro):
function filenameToAlt(filename: string): string {
return filename
.replace(/\.[^.]+$/, "") // remove extension
.replace(/^\d+[-_]?/, "") // remove leading number
.replace(/[-_]/g, " ") // hyphens/underscores → spaces
.replace(/\b\w/g, c => c.toUpperCase()) // capitalize each word
.trim() || title; // fallback to gallery title
}There are two ways to define a cover:
coverImage: ./01-tokyo.jpg- Astro resolves the relative path from
index.mdand optimizes it. - If that same image already exists in the folder's photo list (
hasCoverInFolder), it is not shown twice as a top banner — it only appears in the grid. - If the image is not in the folder (e.g. a dedicated cover image), it is displayed as a banner at the top of the detail page.
- The first image (alphabetically) in the folder is used as the cover in listing cards (
fallbackImage). - On the detail page there is no banner; the grid starts directly.
In src/config.ts:
export const SITE = {
// ...
showGalleries: true, // false → disables gallery section globally
showGalleriesInIndex: true, // include galleries in mixed feeds (effective only when showGalleries=true)
};showGalleries = false:/galleriesindex redirects to 404.src/pages/galleries/[gallery].astroreturns[]fromgetStaticPaths(no gallery detail routes generated).- Gallery nav links are hidden.
- Mixed feeds ignore galleries even if
showGalleriesInIndexistrue.
showGalleries = trueandshowGalleriesInIndex = false:/galleriesremains available.- Mixed listing surfaces remain blog-only.
showGalleries = trueandshowGalleriesInIndex = true:- Galleries are included in mixed listing surfaces.
The integration introduced in commit dbfeb4b unifies blog posts and galleries for global listing surfaces.
/(home feed)/posts(paginated listing)/archives/tagsand/tags/<tag>/rss.xml
- Entries are loaded in parallel with
Promise.alland merged only when both flags are enabled. - Shared helpers (
contentEntry,getSortedPosts,getUniqueTags,getPostsByTag) keep route/path generation and sorting logic consistent for both collections. - Visual differentiation:
Card.astroadds a gallery icon badge whencollection === "galleries".archives/index.astroadds an inline gallery badge in timeline items.
Representative pattern used by listing routes:
const [blogPosts, galleryPosts] = await Promise.all([
getCollection("blog"),
SITE.showGalleries && SITE.showGalleriesInIndex
? getCollection("galleries")
: Promise.resolve([]),
]);GalleryEmbed is a self-contained Astro component that renders the image grid of any gallery inside the body of an MDX post, including its own lightbox.
The component is registered globally in src/layouts/PostDetails.astro:
// PostDetails.astro
import GalleryEmbed from "@/components/GalleryEmbed.astro";
// ...
const { Content } = await render(post, {
components: { GalleryEmbed },
});Thanks to this, in any .mdx file you can use it without importing it:
<GalleryEmbed slug="gallery-name" />| Prop | Type | Default | Description |
|---|---|---|---|
slug |
string |
— | Required. Folder name in src/data/galleries/ |
limit |
number |
6 |
Max images to show. 0 = all |
showLink |
boolean |
true |
Show footer link to the full gallery |
cols |
2 | 3 | 4 |
3 |
Number of grid columns |
{/* Basic usage — first 6 photos, 3 columns */}
<GalleryEmbed slug="my-trip" />
{/* Only 4 photos in 2 columns, no footer link */}
<GalleryEmbed slug="my-trip" limit={4} cols={2} showLink={false} />
{/* Show all photos */}
<GalleryEmbed slug="my-trip" limit={0} />If slug does not match any entry in the galleries collection, the component renders a warning message instead of breaking the build:
⚠️ Gallery my-gallery not found. Make sure src/data/galleries/my-gallery/index.md exists.
- Each
GalleryEmbedcreates its own<dialog>with IDge-lb-{slug}for the lightbox. This allows multiple embeds in the same post without conflicts. - The initialization script uses
document.querySelectorAll("[data-gallery-embed]")and re-runs onastro:after-swapfor View Transitions compatibility. - Images use the same
widthsandsizesas[gallery].astro; Astro does not duplicate optimization — if the same photo appears in both the embed and the detail page, it is optimized only once during the build.
Build time
──────────────────────────────────────────────────────────────────────
src/data/galleries/
<slug>/
index.md ──► "galleries" collection (Astro Content)
*.jpg / *.png ──► import.meta.glob eagerly ──► ImageMetadata[]
│
┌─────────────────────────┴──────────────────────────┐
│ │
/galleries section mixed feed (optional)
(index.astro + [gallery].astro) (enabled by both config flags)
│ │
GalleryCard + detail lightbox shared helpers (contentEntry +
(<Image /> optimized) sort/tag/group utilities)
│ │
└─────────────────────────┬──────────────────────────┘
│
/, /posts, /archives, /tags, /rss.xml
The detail page includes a native lightbox using <dialog> — no external dependencies.
| Action | Result |
|---|---|
| Click on image | Opens the lightbox with that image |
← / → |
Navigate between images |
Esc |
Close the lightbox |
| Click outside the image | Close the lightbox |
‹ and › buttons |
Touch/click navigation |
- The lightbox is a standard
<dialog>usingshowModal()/close(). This blocks background scroll and handles focus accessibly. - Images in the lightbox use the
srcof the Astro-processed version (the largest in the srcset). - The script registers on
astro:after-swapto maintain compatibility with View Transitions. document.body.style.overflow = "hidden"while the lightbox is open to prevent double scrolling in some browsers.
| Viewport | Columns |
|---|---|
< 640px |
1 |
640px – 1023px |
2 |
≥ 1024px |
3 |
| Viewport | Columns |
|---|---|
< 640px |
2 |
640px – 1023px |
3 |
≥ 1024px |
4 |
Grid and lightbox styles are scoped inside <style> in [gallery].astro. GalleryCard styles are scoped in GalleryCard.astro. Hero classes (.archive-hero, .aurora-orb, .hero-badge) come from the global stylesheet (src/styles/global.css) and are reused from the Archive page.
-
Static glob:
import.meta.globrequires a literal string at compile time. It is not possible to make the glob dynamic per gallery — that is why a global glob over all galleries is used and then filtered at build runtime. -
Uppercase extensions: the glob includes variants
.JPG,.JPEG,.PNG,.WEBPto cover photos exported from cameras/phones. If a new format is added (e.g. converted.HEIC), it must be manually added to the glob in both pages (index.astroand[gallery].astro). -
No pagination on detail page: if a gallery has many images (>100), all of them are rendered in the HTML. For very large galleries, pagination or infinite scroll would need to be implemented.
-
Alt text derived from filename: it is automatic but not perfect. For images with non-descriptive names (e.g.
IMG_4532.jpg), the resulting alt is generic. This can be improved in the future with animagesfield in the frontmatter. -
Tag visibility is tied to mixed-feed flags: gallery tags appear in
/tagsonly when bothshowGalleriesandshowGalleriesInIndexare enabled. There is no independent toggle for "include galleries in tags but not in home/posts".
If full control over alt text or order is needed, add to the schema:
// in content.config.ts
images: z.array(z.object({
filename: z.string(),
alt: z.string(),
caption: z.string().optional(),
})).optional(),In [gallery].astro, merge the frontmatter array with the folder images by filename.
Add a ?page=N parameter and paginate images.slice(offset, offset + PAGE_SIZE). For SEO, use <link rel="next"> / <link rel="prev">.
Gallery integration in /tags and /rss.xml currently follows the same gate as the rest of the mixed feed (showGalleries && showGalleriesInIndex).
If finer control is needed, add dedicated flags (for example showGalleriesInTags and showGalleriesInRss) and apply them in the corresponding routes.
Replace the lightbox <img> with a library like PhotoSwipe for pinch-to-zoom support on mobile. PhotoSwipe accepts ImageMetadata and does not require bundling extra images.
Add a coverVideo: z.string().url().optional() field in the schema for galleries that want an animated cover (URL to a short video). In GalleryCard.astro, render a <video autoplay muted loop> instead of <Image>.
See the GalleryEmbed section for full documentation.