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.
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:
- Scan the project for images.
- Hash them to check if they have changed.
- Send new images to a Vision model (Gemini 3 Flash via OpenRouter).
- Save the descriptions to a JSON map.
- 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.