Automating Accessibility: Generating Alt Text with AI

How I automated accessibility across my portfolio by building a build-time pipeline that uses Gemini Flash to generate accurate alt text for hundreds of images.

Pixel art of an astronaut in a wheelchair racing down a hill at sunset.

When building a portfolio site, you eventually hit a wall with images. You want them everywhere to show off your work, but every image needs alt text.

Missing or poor alt text is a major accessibility failure. It hurts screen reader users and it hurts SEO. But manually writing “Black and white selfie of a smiling man…” for the hundredth time is exactly the kind of tedious work I try to avoid.

So, I automated it.

I built a pipeline that uses Vision AI models to analyze images at build time, generates concise descriptions, and injects them into my Astro components. Here is how it works.

The Strategy

I didn’t want a heavy runtime solution. I wanted something that runs once, caches the results, and costs pennies.

The workflow is simple:

  1. Scan the project for images.
  2. Hash them to check if they have changed.
  3. Send new images to a Vision model (Gemini 3 Flash via OpenRouter).
  4. Save the descriptions to a JSON map.
  5. Inject the text automatically in my image components.

The Generation Logic

The core of the system is the prompt. I need descriptions that are functional, not poetic. I used Google’s Gemini 3 Flash because it’s fast, incredibly cheap, and good at visual analysis.

async function generateAltText(
  imagePath: string,
  openrouter: ReturnType<typeof createOpenRouter>
): Promise<string> {
  // Convert AVIF/SVG to PNG base64 for the API
  const imageBase64 = await convertImageToPngBase64(imagePath);

  const { text } = await generateText({
    model: openrouter.chat('google/gemini-3-flash-preview'),
    messages: [
      {
        role: 'user',
        content: [
          {
            type: 'text',
            text: 'Generate a concise, descriptive alt text for this image. Max 10-15 words. Focus on the main subject. Return only the text.',
          },
          {
            type: 'image',
            image: imageBase64,
          },
        ],
      },
    ],
  });

  return text.trim();
}

One technical hurdle: APIs rarely accept optimized formats like AVIF. I had to include a step using sharp to convert images to PNG buffers on the fly before sending them out.

Smart Caching

Processing hundreds of images on every build would be slow and expensive. I implemented a content-addressable cache using SHA256 hashing.

The script looks at the file’s modification time and size, plus a sample of the content.

function getFileHash(filePath: string): string {
  const stats = fs.statSync(filePath);
  const content = fs.readFileSync(filePath);
  // Fast hash based on metadata + content sample
  return sha256(
    `${stats.mtime.getTime()}-${stats.size}-${content.toString('hex').substring(0, 1000)}`
  );
}

If the hash matches what is in my image-metadata.json, we skip the API call. This means subsequent builds are nearly instant, and I only pay when I add or modify an image.

Integration with Astro

The output of the script is a simple JSON file mapping filenames to metadata:

{
  "sarthak-photo.avif": {
    "altText": "Black and white selfie of a smiling man wearing sunglasses on a beach.",
    "hash": "22150ba448..."
  }
}

Now, instead of manually passing alt props every time, my components look it up automatically.

The PostImage Component

In my PostImage.astro component, I import the metadata and perform a lookup based on the filename.

---
import imageMetadata from '@/data/image-metadata.json';
import { Picture } from 'astro:assets';

const { thumbnail, alt } = Astro.props;

// Lookup metadata by filename
const filename = thumbnail;
const metadata = filename
  ? (imageMetadata as Record<string, { altText?: string }>)[filename]
  : null;

// Allow manual override, otherwise use AI text
const altText = metadata?.altText || alt || '';
---

<Picture src={image} alt={altText} * ... styling props ... * />

This pattern extends to my Carousels and Project Cards. If I forget to add an alt prop manually, the AI coverage kicks in.

Handling Conflicts

Since I map metadata by filename, duplicates in different directories were a risk. I added a pre-validation step that scans the filesystem and kills the build if it detects ambiguous filenames.

if (conflicts.length > 0) {
  console.error('❌ Filename conflicts found:');
  conflicts.forEach(c => console.error(`   ${c}`));
  process.exit(1);
}

It’s a strict rule, but it keeps the lookup logic fast and simple.

The Result

I added a script to my package.json to run this before the main build.

"scripts": {
  "alt-text": "tsx scripts/generate-image-metadata.ts --alt-texts",
  "build": "tsx scripts/generate-image-metadata.ts --alt-texts && astro build"
}

The result is comprehensive accessibility coverage with zero runtime overhead. The site remains a static build, the images remain optimized, but now screen readers actually get useful context instead of filenames or silence.

It is a small automation, but it solves a persistent problem. And honestly, the AI is often better at describing the images than I am.

Subscribe to my newsletter

I send out a newsletter every week, usually on Thursdays, that's it!

You'll get two emails initially—a confirmation link to verify your subscription, followed by a welcome message. Thanks for joining!

You can read all of my previous issues here

Related Posts.