This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Immich Folio is a self-hosted photography portfolio that acts as a secure reverse proxy between visitors and a private Immich instance. It serves albums via an image proxy so the Immich server and API key are never exposed to the public internet. Asset UUIDs are AES-256 encrypted into opaque URL tokens.
npm run dev # Start dev server at http://localhost:3000
npm run build # Production build (must pass before any PR)
npm run lint # ESLint
npm run format # Prettier (write)
npm run format:check # Prettier (check only)
npx tsc --noEmit # TypeScript type-check (must be 0 errors before any PR)
# Unit tests (Vitest, lib/__tests__/ only)
npm run test # Watch mode
npm run test:unit # Single run
npm run test:coverage
# E2E tests (Playwright, requires dev server running or starts it automatically)
npm run test:e2eFormatting caution: Running npm run format at the repo root applies changes to the entire codebase and pollutes diffs. Prefer formatting only modified files.
Configuration is loaded once at startup (cached in a module-level singleton, re-read in dev mode per request).
lib/env.ts— parses and validates all environment variables into a typedEnvobject. All env access in the codebase must go through this module.lib/config/schema.ts— TypeScript types forAppConfig,GalleryYaml,SettingsYaml,SubpageConfig,GridConfig,ThemeConfig, etc., plus theslugify()utility.lib/config/index.ts—getConfig(): readscontent/gallery.yamlandcontent/settings.yaml, merges them into a singleAppConfig. Also exportsbuildSubpageGrid()and re-exports from schema/theme.lib/config/theme.ts— six built-in theme presets (studio,minimal,editorial,classic,noir,monograph) andresolveTheme()which merges partial overrides over a preset.
getConfig() returns a needsSetup: true dummy config (instead of throwing) when gallery.yaml or credentials are missing — this lets the app render a SetupScreen instead of crashing.
content/gallery.yaml— gallery structure: hero asset IDs, standalone albums, subpages (with optional sections, passwords, per-subpage grid overrides). Usegallery.yaml.exampleas reference.content/settings.yaml— site-wide settings: title, subtitle, theme, grid defaults, footer, legal, map, transitions, SEO. Usesettings.yaml.exampleas reference.content/about.md— Markdown with frontmatter for the about page (portrait, name, location, gear).
Singleton ImmichClient class exported as immich. All Immich API calls are server-side only. Key design points:
- Album allowlist —
getAlbums()fetches?shared=truealbums but only returns IDs listed inconfig.albums. Requests for unlisted albums are silently rejected. - Request coalescing — pending promises are stored in
Map<id, Promise>fields to deduplicate concurrent requests for the same album/asset (important forPromise.allcalls in grids). - In-memory LRU cache (
lib/cache.ts) — 200-entry LRU. Entries carry two deadlines:staleAt(theCACHE_TTLwindow, after whichget()reports a miss) andhardExpiresAt(STALE_MAX_AGE, after which the entry is dropped). Between the two,getStale()still returns the data —lib/immich.tsfalls back to it when a request fails withImmichUnavailableError, so the gallery keeps serving the last known albums during an Immich outage instead of erroring. Definitive 404s are cached as aMISSINGsentinel under the normal TTL and are deliberately excluded from the stale window. Cache keys:albums-list,album-<id>,asset-<id>. - Image streaming —
streamAsset()proxies binary responses; never loads the full image into memory.
encodeAssetId(uuid) / decodeAssetId(token) — AES-256-GCM with a deterministic IV derived from the asset ID (same UUID → same token, enabling browser caching). Token format: v2:<base64url(iv+authTag+ciphertext)>. The encryption key is derived from AUTH_SECRET via SHA-256. Legacy CBC tokens are still decoded for backward compatibility.
| Route | Purpose |
|---|---|
GET /api/image/[id] |
Image proxy — decodes token, rate-limits, streams from Immich |
GET /api/exif/[id] |
EXIF data for lightbox panel |
POST /api/auth |
Password submission → sets HttpOnly cookie |
GET /api/og |
Dynamic OG image generation (rate-limited) |
GET /api/map |
Aggregated GPS coordinates for map view |
GET /api/health |
Health check |
GET/POST/DELETE /api/admin/auth |
Admin login, session check, logout |
GET/PUT /api/admin/gallery |
Read/write gallery.yaml |
GET/PUT /api/admin/settings |
Read/write settings.yaml |
GET /api/admin/albums |
Browse all shared Immich albums (admin-only) |
POST /api/admin/reload |
Invalidate config + Immich cache |
The image proxy maps requested pixel widths (?w=) to Immich size tiers: ≤250px→thumbnail, ≤1440px→preview, >1440px→original (lib/imageSize.ts).
?size= (written by lib/urls.ts) acts as a ceiling, not an override. When both parameters are present the smaller tier wins, so ?w= can narrow the request but never widen it — next/image emits widths up to 3840, so letting width win outright would serve full-size originals to every large display.
Single catch-all route handles three cases:
/[subpage-slug]/[album-slug]— renders album detail with back-link to subpage/[subpage-slug]— if subpage has >1 album, rendersSubpageGridView; if exactly 1 album, rendersAlbumDetailViewdirectly/[album-slug]— standalone album detail
All pages are dynamic = 'force-dynamic' (no SSG; requires live Immich).
Per-subpage and per-album password gating using HMAC tokens in HttpOnly cookies (no database). Cookie names: lb_auth_<slug> (subpage) and lb_auth_album_<slug> (album). Password storage supports plaintext (deprecated, logs a warning with recommended scrypt hash), scrypt:salt:hash format, and rejects legacy bcrypt. Token expiry: 24 hours.
In-memory sliding-window rate limiter. Important: in-memory only — does not work across multiple Node.js instances. Uses FIFO eviction (not reject-on-full) to prevent DoS via store flooding. Configurable via RATE_LIMIT_RPM. TRUSTED_PROXY_HOPS must be set to the number of reverse proxies in front of the app (nginx = 1); the client IP is then read that many entries from the right of X-Forwarded-For, which proxies append to. Without it the IP comes from a spoofable header. Bucket keys are namespaced per endpoint (image:, map:, auth:, …) so limits don't collide.
Theme is applied via CSS custom properties on the <html> element (data-preset, data-grain, data-photo-frame, etc.) and inline style vars (--accent, --font-serif, etc.). Theme preset CSS files live in app/themes/. Base tokens are in app/tokens.css. Vanilla CSS throughout — no Tailwind, no CSS-in-JS.
A custom loader (lib/immichLoader.ts) maps next/image requests to /api/image/[token]?w=<width>, keeping all image traffic through the proxy.
Visual page builder and settings editor at /admin. Enabled by setting ADMIN_PASSWORD env var.
lib/admin/auth.ts— HMAC-signed session tokens (HttpOnly cookiefolio_admin_session), constant-time password verification, 24h expiry.lib/admin/yaml-service.ts— Atomic YAML read/write with automatic backups tocontent/.backups/(max 10 per file). Writes use temp-file + rename pattern.app/api/admin/auth— POST login, GET session check, DELETE logout.app/api/admin/gallery— GET/PUT forgallery.yaml.app/api/admin/settings— GET/PUT forsettings.yaml.app/api/admin/albums— GET lists ALL shared Immich albums (bypasses allowlist, admin-only).app/api/admin/reload— POST invalidates config cache + Immich cache.app/admin/components/PageBuilder.tsx— Tree editor for hero, standalone albums, subpages, and sections.app/admin/components/AlbumPicker.tsx— Modal to browse/search all Immich albums.app/admin/components/SettingsEditor.tsx— Sidebar-nav settings form (theme, grid, footer, legal, SEO).
After saving, invalidateConfigCache() is called so the next request picks up the new YAML without restart.
Applies CSP (with per-request nonce), HSTS, and other security headers on all non-API, non-static routes. The nonce is passed to pages via the x-nonce request header.
Next.js 16 renamed the middleware file convention to proxy: the file is proxy.ts and it exports proxy() (not middleware()). The config.matcher export is unchanged.
- TypeScript strict mode — no untyped
any; use@ts-expect-error(not@ts-ignore) with a comment when suppressing type errors. - Server Components by default — add
'use client'only when browser APIs or React state/effects are needed. - All Immich data flows server-side — raw asset UUIDs must never appear in client-rendered HTML or JS. Always use
encodeAssetId()/imageUrl()/exifUrl()fromlib/urls.tsbefore passing IDs to components. - Rate-limit all expensive endpoints — apply
checkRateLimitfromlib/rate-limit.tsto any route doing heavy computation or upstream API calls. - Commit style — Conventional Commits:
feat:,fix:,security:,docs:,chore:. - Route handlers are testable —
vitest.config.tsincludesapp/**/__tests__/**/*.test.ts. Logic that lives in a route (auth guards, header sanitisation) belongs in a route-level test; do not extract it intolib/purely to make it reachable by the test runner.