Images in Astro: How to Add, Optimize and Manage Local Images

How Astro handles images differently from WordPress — the Image component, heroImage in frontmatter, Markdown syntax vs MDX component, and a practical workflow for managing image files.

Quick answer

How do you add images in Astro?

Two ways: use Markdown syntax ![alt text](path/to/image.jpg) directly in .md or .mdx files, or import and use Astro's Image component in .mdx files for more control over dimensions and optimization. For hero images, define heroImage in frontmatter using image() in the Zod schema.

VS Code showing Astro project with images folder and frontmatter heroImage field
First-hand experience: Based on direct hands-on use. Every image on astro-content-lab.vercel.app — hero images, inline article images — was added using exactly the methods in this article.

In WordPress, adding images is almost thoughtless. Upload to Media Library, select the image, add alt text, insert. The theme handles the rest — featured images, sizes, cropping, all defined by theme settings.

Astro is different. Not harder — but more deliberate.

You decide where images live. You decide how they’re displayed. You decide what gets optimized and how. That’s more work upfront and significantly more control long-term.


Where to put images

Astro projects have two places for images:

src/assets/images/ — for images you want Astro to optimize at build time. Astro converts them to WebP, generates correct dimensions, enables lazy loading. This is where content images belong.

public/ — for images served as-is, no processing. Favicons, open graph images, files that need a stable URL. Astro copies these directly to the output folder without touching them.

For blog posts, reviews, guides — use src/assets/images/.

Create the folder structure once and stick to it:

mkdir -p src/assets/images/posts
mkdir -p src/assets/images/reviews
mkdir -p src/assets/images/compares
mkdir -p src/assets/images/guides

Method 1: Markdown image syntax

The simplest way to add an image to a post is standard Markdown syntax:

![Alt text describes the image](../../assets/images/posts/hello-world.jpg)

The ![]() syntax works in both .md and .mdx files. Astro processes it through the image optimizer automatically — no extra import, no component needed.

The path is relative to the content file. From src/content/posts/, going up two levels (../../) reaches src/, then down to assets/images/posts/.


Method 2: Astro Image component in MDX

For .mdx files, you can import and use Astro’s <Image /> component directly:

import { Image } from 'astro:assets'
import myImage from '../../assets/images/posts/what-is-mdx.jpg'

<Image
  src={myImage}
  alt="MDX components render inline with content"
  width={800}
  height={420}
/>

This gives you explicit control over dimensions, which matters when you need a specific aspect ratio or want to prevent layout shift during load.

When to use which:

Markdown ![]()Astro <Image />
Works in .md files
Works in .mdx files
Import requiredNoYes
Explicit dimensionsNoYes
Auto-optimizationYesYes
Best forQuick inline imagesPrecise layout control

For most inline content images, Markdown syntax is the right choice. Reach for <Image /> when you need exact dimensions or are already using MDX components in that file.


Hero images via frontmatter

For content types where every entry has a header image — blog posts, reviews, guides — the right approach is a heroImage field in the frontmatter rather than the first line of the content.

This separates the metadata from the body. The template decides where and how the hero image displays. The content file just declares what the image is.

Step 1: Update the schema

In src/content.config.ts, use image() instead of z.string() for the hero image field:

const posts = defineCollection({
  loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/posts' }),
  schema: ({ image }) => z.object({
    title: z.string(),
    description: z.string(),
    publishedAt: z.date(),
    heroImage: image().optional(),
    heroImageAlt: z.string().optional(),
  }),
})

Note schema: ({ image }) => — the schema is now a function that receives the image helper. This is Astro-specific syntax that enables build-time image validation and optimization metadata.

Step 2: Add heroImage to frontmatter

---
title: Hello World
description: Every developer starts here. So do I.
publishedAt: 2026-06-17T00:00:00Z
heroImage: ../../assets/images/posts/hello-world.jpg
heroImageAlt: A bench against a wall — the simplest things say the most
---

Step 3: Display in the template

---
import { Image } from 'astro:assets'
---

{post.data.heroImage && (
  <div class="hero-image">
    <Image
      src={post.data.heroImage}
      alt={post.data.heroImageAlt || post.data.title}
      width={1200}
      height={630}
    />
  </div>
)}

Why image() instead of z.string()

This confused me at first. Both seem to store an image path — why is image() different from z.string()?

z.string() just validates that the value is a string. It doesn’t care what the string says.

image() does three things z.string() can’t:

  1. Validates the file exists — if the image path is wrong, Astro throws a build error immediately instead of serving a broken image
  2. Reads image dimensions — Astro knows the width and height at build time, which prevents layout shift when the image loads
  3. Enables optimization — the image gets processed through Astro’s pipeline: converted to WebP, compressed, properly sized

One field declaration. Build-time validation, dimensions, and optimization — all included.


The listing page thumbnail

Hero images shouldn’t only appear on detail pages. Listing pages — /blog, /reviews — benefit from thumbnails that help readers scan and decide what to click.

In the listing page template, display a smaller version of the same hero image:

{post.data.heroImage && (
  <div class="post-thumbnail">
    <Image
      src={post.data.heroImage}
      alt={post.data.heroImageAlt || post.data.title}
      width={400}
      height={210}
    />
  </div>
)}

Same field, different dimensions. Astro generates an appropriately sized version for each use.


The repo size problem

Here’s something WordPress developers don’t think about: in Astro, images are files in your Git repository.

Every image you add to src/assets/images/ gets committed to Git. The repository grows. Cloning gets slower. Build times increase.

For a personal blog with 50 posts — totally fine. Each image is maybe 100-200KB. That’s 5-10MB total. No problem.

For a content site with 500 posts, product reviews with multiple images each, comparison tables with screenshots — you’re looking at hundreds of megabytes in the repo. That becomes a real problem.

The solution is an external image CDN. Store images outside the repo, reference them by URL. The repo stays lean regardless of how much content you have.

That’s exactly what the next article covers — Cloudinary, how it works, and when to make the switch from local images.


Frequently Asked Questions

What is the difference between img tag and Astro Image component?
A plain HTML img tag works but bypasses Astro's optimization. The Image component automatically converts to WebP, enforces width and height to prevent layout shift, and lazy loads by default. For images in src/assets/, use Image component or Markdown syntax — both get optimized.
Where should I put images in an Astro project?
src/assets/images/ for images you want optimized at build time — blog post images, hero photos, article illustrations. public/ for files that need a stable URL without processing — favicons, open graph images. For content images, src/assets/ is almost always correct.
Can I use Markdown image syntax in MDX files?
Yes. ![alt text](path) works in both .md and .mdx files. The Astro Image component gives explicit dimension control, but Markdown syntax is simpler and sufficient for most inline content images.
What is heroImage: image() in a Content Collections schema?
image() is an Astro-specific Zod helper that validates the image file exists, reads its dimensions at build time, and enables optimization metadata. Unlike z.string(), it checks that the path points to a real file — a wrong path throws a build error rather than serving a broken image.
What happens when the repo gets too large from images?
Clone times slow down, builds take longer, and Git history gets bloated. For a personal blog this rarely matters. For a high-volume content site, the solution is an external image CDN — upload images there, reference by URL, keep the repo lean. The next article covers Cloudinary for exactly this use case.