Fine art photography portfolio.
Stack: Astro 6 (static + hybrid) · Svelte 5 islands · Cloudflare Pages · Resend (outbound email).
src/
├── content/
│ ├── site/main.md # site title, tagline, social links, about (body)
│ ├── projects/*.md # each project: title, description, year, optional cover
│ ├── photos/*.md # each photograph; references a project, optionally a story
│ └── stories/*.md # standalone stories that can be linked from a photo
├── content.config.ts # zod schemas + cross-collection references
├── layouts/
│ └── Layout.astro # shared chrome — reads site.md for title/social
├── components/
│ ├── Gallery.svelte # interactive grid + lightbox
│ └── ContactForm.svelte
└── pages/
├── index.astro # lists projects (with cover from first photo)
├── about.astro # renders site/main.md body
├── contact.astro # contact form
├── projects/[slug].astro # one page per project
├── photos/[slug].astro # one page per photograph (+ EXIF + story link)
├── stories/[slug].astro # one page per story (+ linked photos)
└── api/contact.ts # Worker route — sends email via Resend
All pages prerender to static HTML. Only /api/contact runs as a Worker.
Everything personal lives in src/content/. You don't touch any TypeScript to
publish new work.
Edit src/content/site/main.md. The frontmatter sets the site title, tagline,
your email, and your social links. The markdown body becomes the About page.
Create src/content/projects/<slug>.md:
---
title: "Coastlines"
description: "A short tagline shown on the homepage card."
year: 2025
order: 2
---
Optional longer artist statement (markdown body).
Create src/content/photos/<slug>.md:
---
title: "Cliff at dusk"
project: "coastlines" # required — must match a project slug
story: "october-storms" # optional — match a story slug
year: 2025
location: "Étretat, France"
image: "/images/cliff-dusk.jpg" # or remote URL
alt: "Cliff face at dusk with breaking waves"
order: 1
exif:
camera: "Sony A7R IV"
lens: "70-200mm f/2.8"
focalLength: "200mm"
aperture: "f/8"
shutter: "1/500s"
iso: 400
---
Create src/content/stories/<slug>.md:
---
title: "October storms"
date: 2025-10-12
excerpt: "Three days of weather and what it taught me."
---
Markdown body of the story.
Then reference it from a photo via story: "<slug>". The photo page links to
the story; the story page lists the photos that reference it.
bun install
bun run dev # http://localhost:4321 — hot reload via Vite
bun run build # outputs dist/ + Worker entry
To exercise the Worker runtime locally (so the contact form actually fires):
bun add -d wrangler
cp .dev.vars.example .dev.vars # then fill in real values
bun run build
bunx wrangler dev # http://localhost:8787
One-time setup
i3oc9i/photo_folio_2.bun run build · Output: dist · Framework preset: Astro.BUN_VERSION = 1.3.13 so Cloudflare's builder uses bun.Secrets (production)
Set with wrangler from your machine:
bunx wrangler secret put RESEND_API_KEY
bunx wrangler secret put CONTACT_TO
bunx wrangler secret put CONTACT_FROM
CONTACT_FROM must be an address on a domain you've verified in Resend. For testing you can use Resend's [email protected].
After the first push to main, Cloudflare auto-deploys on every commit.
src/content/photos/<slug>.md:---
title: "Forest light"
year: 2024
location: "Vosges, France"
series: "landscapes"
image: "/images/forest-light.jpg"
alt: "Sunlight piercing through pine trees"
order: 1
---
public/images/ (or use a remote URL).src/assets/ + <Image /> from astro:assets for AVIF/WebP srcset.<Turnstile siteKey={…} /> widget in ContactForm.svelte and verify the token in api/contact.ts.wrangler.jsonc.