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 a person writing with a quill by candlelight in a room.

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 hashes the file content directly to detect changes:

function getFileHash(filePath: string): string {
  const content = fs.readFileSync(filePath);
  return crypto.createHash('sha256').update(content).digest('hex');
}

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.

One detail to watch out for: If you update an image’s content but run the script without the AI flag (e.g., just to update placeholders), you must ensure you don’t save the new hash with the old text. My script explicitly clears the cached alt text if the hash changes but AI generation is skipped, preventing stale descriptions from persisting.

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.