skip to content
walterra.dev
Table of Contents

Since my photo stream starts to show up in Google search, I noticed the index pages needed some SEO love. Turns out all these pages just said “photos” in the title.

walterra.dev photo stream index pages with poor search engine indexing.

After today’s sprint they’re actually descriptive with counts, date ranges, locations, tags - the works.

The real adventure was getting OG images working with Satori. I wanted a 3x3 grid of photos for social sharing cards. Sounds simple enough right?

Never touched Satori myself, so today’s learnings: You need to inline all CSS, get rid of those Tailwind classes! It cannot include images from the local file system, they need to be either absolute URLs or base64 encoded.

I used Claude Code to work on this but it was kinda blind debugging issues. So I updated the dev server to log both to console and a file. This is how the commands look that give Claude Code visibility into what’s going on with the dev server:

"dev": "FORCE_COLOR=1 astro dev 2>&1 | while IFS= read -r line; do echo \"[$(date '+%Y-%m-%d %H:%M:%S')] $line\"; done | tee logs/dev-$(date +%Y%m%d-%H%M%S).log",
"logs:clean": "find logs/ -name '*.log' -mtime +7 -delete 2>/dev/null || true",
"logs:tail": "ls -t logs/*.log 2>/dev/null | head -1 | xargs tail -n 100 2>/dev/null || echo 'No log files found'",
"logs:list": "ls -la logs/*.log 2>/dev/null || echo 'No log files found'",
"logs:follow": "ls -t logs/*.log 2>/dev/null | head -1 | xargs tail -f 2>/dev/null || echo 'No log files found'",

On top of that Claude Code can read and analyse images! This means I could ask it to just have a look at e.g. http://localhost:4321/og-image/photos.png and verify the output worked as intended. It’s image reading util cannot read from localhost though, the workaround was to let it save it to /tmp/photos.png first and then access it via the local file systemm.

So after I had that setup in place, Claude Code was much more efficient in fixing issues. It got the photo grid spacing right with equal gaps between rows and columns eventually.

Next it turned out the image quality was messed up resizing the original images into thumbnails for the grid. Claude one-shotted some nice Sharp (AstroJS includes it) preprocessing:

await sharp(Buffer.from(photoResponse))
.resize(181, 181, { kernel: sharp.kernel.lanczos3 })
.jpeg({ quality: 85 })
.toBuffer()

Everything was almost in place now, but turned out we had a routing bug where the first photos page (/photos) wouldn’t generate OG images. The paginated pages worked fine but page 1 was special. Had to create a separate route file just for that case. Astro’s routing can be quirky sometimes.

Happy with the end result now! Photo pages now have proper titles like “Photos from Greece 2018 (7 photos)” and social cards show actual photo previews instead of generic placeholders.

Sample auto-generated og-image for a photo index page

Next up at some point is hopefully releasing that whole photo stream thingy as an open source AstroJS extensions, stay tuned!

Update after first real deploy:

So everything worked perfectly in dev mode. Social cards looked great, photo grids were crisp, SEO was sorted. Deployed to production feeling pretty good about myself.

Then I checked the actual social sharing cards and… empty placeholders. Just numbers. 1, 2, 3 instead of beautiful photos. Classic.

The problem? My processPhotoImages function in src/utils/photo-og-image.ts was trying to read original image files using hardcoded absolute paths. Works fine in dev where files stay put, but in production builds Astro processes everything and dumps it in dist/_astro/ with hashed filenames.

So the function was looking for /src/assets/photos/greece/IMG_1234.jpg but production had /dist/_astro/IMG_1234.B7x9k2.jpg or whatever cryptic hash Astro decided on.

Claude Code helped debug this mess. We modified the image processing logic to detect development vs production and handle both cases:

const isDev = import.meta.env.DEV;
const imageDir = isDev
? path.join(process.cwd(), 'src', 'assets')
: path.join(process.cwd(), 'dist', '_astro');

Had to scan the _astro directory for processed files and match them back to original filenames. Not pretty but it works.

The lesson here is obvious but I keep forgetting it: Don’t just test in dev mode but make use of the astrojs build and preview feature to review the local build. Would have caught this immediately instead of discovering it post-deployment like an amateur.

Seems fixed now though. Social images show proper photo grids in both dev and production. Just wish I’d been smarter about testing the build first.

Round Two: CI Strikes Back

Apparently I wasn’t done making the same mistakes. After fixing the dev vs production paths, I pushed to CI feeling confident. Wrong again.

CI failed with a beautiful error:

Failed to process production image: /Users/<some-path>/dist/_astro/DSC01391.CCTzi1sx.jpeg
Error: Input file is missing

Classic Walter move - I had hardcoded my local machine paths (/Users/<some-path>/...) right into the “fix”. Of course CI runs on /home/runner/work/<some-path>/... because why would anything be simple?

The irony wasn’t lost on me. Fixed hardcoded paths with… more hardcoded paths. Sometimes I wonder how I managed to not break every production system I’ve ever touched.

Claude Code and I went back and made the paths actually dynamic this time:

// Before (embarrassing)
const imagePath = `/Users/<some-path>/dist/_astro/${processedName}`;
// After (sensible)
const imagePath = path.resolve(process.cwd(), 'dist', imageSrc.substring(1));

Also cleaned up the development path handling:

const projectRoot = process.cwd();
const imageDir = isDev
? path.join(projectRoot, 'src', 'assets')
: path.join(projectRoot, 'dist');

Used path.isAbsolute() instead of checking for specific path prefixes too. Much more robust.

Did a clean local build (rm -rf dist && pnpm build) to verify everything still worked. Photo grids generated properly, no missing files, no hardcoded paths anywhere.

The fix is now actually environment-agnostic. Should work on CI, production, or any other machine that builds this thing.

Lesson learned (hopefully for real this time): When you catch yourself hardcoding absolute paths, stop and think about where else this code might run. CI environments are not your laptop. Revolutionary concept, I know.