A minimal, fast photography portfolio with two blogs and a fixed left-hand navigation (Digital · Analog · Calendar · Tech Blog · Photo Blog · About · Contact · License) and responsive photo galleries. Built with Astro and output as a fully static site you can deploy to any static host (Netlify, Vercel, GitHub Pages, Cloudflare Pages, S3/CDN, …).
It ships with tasteful, generated placeholder images and example posts, so it looks complete the moment you run it. Swap in your own name, links, and photos and you have a finished site.
- Digital is the home page — your digital photographs.
- Analog is a second gallery for film work.
- Calendar shows one photo per month, newest first.
- Tech Blog and Photo Blog are Markdown/MDX blogs with a share button.
- About / Contact / License / Privacy are simple, editable pages.
- Layout adapts from a sidebar (desktop) to a top bar + menu (tablet & phone).
- Click any photo for a full-screen lightbox (arrow keys / swipe-friendly).
- Light/dark theme toggle, privacy-friendly by default, strong SEO out of the box.
Everything you see is a placeholder. The photos are generated gradients, the name is "Your Name", and the two blog posts are examples. The steps below walk you through making it your own.
Requirements: Node ≥ 22.12 (required by Astro 7; this repo's .nvmrc
pins major 22).
# Use this repo as a GitHub template (green "Use this template" button), or clone:
git clone https://github.com/XD-QIN/astro-photo-folio.git my-site
cd my-site
npm install
npm run devOpen http://localhost:4321. Edits to pages, styles, src/config.ts, and
photos in src/assets/** hot-reload instantly. Press Ctrl+C to stop.
To preview the real production build (optimized WebP, sitemap, etc.):
npm run build
npm run previewSecurity headers in
public/_headersare applied by your static host (e.g. Netlify or Cloudflare Pages), so they won't appear undernpm run preview— that's expected.
Work top to bottom; each step is independent.
This one file drives the brand, page titles, SEO, JSON-LD, and llms.txt:
export const site = {
name: 'Your Name',
nameZh: '', // optional second-script name (see §4); '' hides it
title: 'Your Name',
description: 'A minimal photography portfolio and blog…',
};
export const social = {
instagram: 'https://www.instagram.com/yourusername',
linkedin: 'https://www.linkedin.com/in/yourusername',
github: 'https://github.com/yourusername',
};To change the social icons themselves, edit the <Icon> list in
src/components/Sidebar.astro and the links array in src/pages/contact.astro
(both reference social). An unused icon (scholar) is included if you'd rather
swap one out.
- Drop
.jpg,.png, or.webpfiles into the folder for that gallery — they appear automatically. - Filename = order + caption. Files sort by name, so prefix them to order
(
01-…,02-…); the rest becomes the caption:01-golden-hour.jpg→ "Golden hour." - Delete the bundled
digital-01.jpg …/analog-01.jpg …placeholders once you add your own. - Upload full-resolution exports — see §3 How photos are served for how they're downscaled for the web.
- Optional richer captions ("Month Year, Location") live in
src/data/galleries.ts, keyed by filename.
The Calendar shows one photo per month — every entry that has a matching image, newest first. To add a month:
-
Drop the image in
src/assets/calendar/(e.g.2026-07.jpg). -
Add an entry in
src/data/calendar.ts:{ year: 2026, month: 7, location: 'Lisbon, Portugal', file: '2026-07.jpg' },
It renders as "July 2026, Lisbon, Portugal." Entries whose image isn't present yet are skipped, so you can stage future months.
src/pages/about.astro— your bio and the little "fact" cards (rename/remove them freely).src/pages/contact.astro— the social links shown as cards.src/pages/license.astro— image-usage terms shown to visitors.src/pages/privacy.astro— a privacy-friendly starting policy. Review it before publishing (it's a sensible default, not legal advice) and update thelastUpdateddate.
Each blog is a folder of Markdown files. Drop in a .md (or .mdx) file and
it appears — newest first, with the filename becoming the URL slug
(my-post.md → /blog/tech/my-post). The template includes one example post in
each folder (getting-started.md, a-first-walk.mdx) — read them, then delete
them. Until a blog has a post, it shows "Please wait patiently for the first
post."
Start every file with frontmatter:
---
title: 'A clear, specific title'
description: 'One or two sentences — shown on the index and as the meta description.'
pubDate: 2026-06-01
tags: ['astro', 'web'] # optional
cover: '../../assets/blog/my-cover.jpg' # optional lead image
coverAlt: 'Describe the image for screen readers.'
draft: false # true = hidden from the production build
----
Code blocks are highlighted with Prism via CSS classes; theme colors live in
global.cssunder "Prism syntax theme." -
In-post photos & galleries (same responsive WebP pipeline + lightbox as the galleries): put images in
src/assets/blog/and use the.mdxextension so you can drop in components — noimportof the image needed, just reference by filename:import Photo from '../../components/Photo.astro'; import Gallery from '../../components/Gallery.astro'; <Photo src="lake-fog.jpg" caption="Lake, fog" /> <Gallery photos={["01-ridge.jpg", "02-coast.jpg"]} />
Set site to your real URL:
site: 'https://your-domain.com',Everything URL-bound — canonical tags, Open Graph, sitemap-index.xml,
robots.txt, llms.txt, JSON-LD — derives from this single value.
The template generates a neutral "aperture seal" mark. To use your own, replace
src/assets/logo-seal-light.png (your mark on a transparent background) and
src/assets/logo-seal-dark.png (your mark on white), then run npm run gen:icons
(see §5). Or just replace src/assets/seal.png / seal-dark.png directly.
The site never serves your full-resolution original. At build time every
photo is converted to responsive WebP and capped at MAX_WEB = 3840px
(4K) on the long edge — crisp on high-res displays, but a downscaled,
recompressed derivative rather than your printable original. A post-build step
(scripts/strip-unreferenced-assets.mjs) then deletes any source originals Astro
copies out while transforming, so only the web renditions ship.
- Sharper 8K viewing: raise
MAX_WEBinsrc/lib/photos.ts(e.g.5120). Lower it for more protection. - Right-click, drag-to-save, and selection are disabled on photos (a deterrent — screenshots are always possible). No watermark.
- Note: the full-resolution files you place in
src/assets/**live in your Git repo (not on the website). If the repo is public and you don't want originals there either, keep a private repo or store originals elsewhere.
These scripts produce everything the template ships with — handy if you change
your name, tweak the logo, or want fresh placeholders. They need no network and
use the sharp that ships with Astro.
npm run setup # runs all three of the steps below, in order
# — or individually —
npm run gen:brand # writes the two source seals (your logo mark)
npm run gen:icons # derives seal.png/seal-dark.png + favicons from them
npm run gen:placeholders # rebuilds gallery/calendar/blog placeholders + og.jpg- The OG share image (
public/og.jpg) reads your name fromsrc/config.ts, so re-runnpm run gen:placeholders -- --og-onlyafter a name change to refresh just that image. - Once you've added your own photos and logo, you can delete the
scripts/you no longer need.
If you set site.nameZh (e.g. a Chinese 中文名), it shows under the brand and on a
couple of pages, using your system's CJK serif. For pixel-identical rendering on
every device, self-host a tiny font subset containing just your characters:
pip install fonttools brotli
pyftsubset NotoSerifCJKsc-Regular.otf --text="你的名字" \
--flavor=woff2 --output-file=public/fonts/name.woff2Then add an @font-face (and a unicode-range for just those characters) in
src/styles/global.css and put its family first in --serif-cjk. See the
comments at the top of global.css.
Every post has a Share button using the device's native share sheet where available, otherwise copying the link to the clipboard — entirely client-side, no third-party script.
There's a placeholder slot in src/layouts/BlogPost.astro marked
── Comments ──. Free options: Giscus (GitHub
Discussions — best for a tech blog), utterances (GitHub
Issues), Cusdis (self-hosted, anonymous), or
Cactus Comments (Matrix). Any embedded widget loads a
third-party script, so also add its domain to the Content-Security-Policy in
public/_headers.
Built to be found and cited by search engines and AI answer engines, while staying out of AI training sets.
- Crawler stance (
src/pages/robots.txt.ts): search + AI search/citation crawlers are allowed; AI training / bulk crawlers (GPTBot,CCBot,ClaudeBot, …) are blocked. Edit theALLOW/BLOCKlists to change it.robots.txtis advisory; if your host or CDN can block by User-Agent, you can additionally enforce the block list there. (Search engines, incl. Google Images, aren't on the list and pass through untouched.) - Sitemap via
@astrojs/sitemap, linked fromrobots.txt. llms.txt(src/pages/llms.txt.ts): a curated Markdown index for AI tools (see https://llmstxt.org/).- Structured data (JSON-LD): a
Person+WebSitegraph on every page, plus anImageGalleryofImageObjects per gallery. - Per-page
<title>, meta description, Open Graph + Twitter card, and a branded share image atpublic/og.jpg. - Photo SEO is text-driven — name files descriptively (
lake-fog.jpg, notIMG_2451.jpg): the name becomes the image'salttext.
robots.txtis advisory. TheCross-Origin-Resource-Policyheader (inpublic/_headers) also blocks other sites from hot-linking your images on hosts that apply it.
- Content-Security-Policy served as an HTTP header from
public/_headers:default-src 'self'locks scripts/styles/images/fonts to the same origin, with no third-party allowances. - Security headers in
public/_headers:X-Frame-Options,Cross-Origin-Resource-Policy(anti-hotlinking), HSTS,X-Content-Type-Options,Referrer-Policy,Permissions-Policy. These apply once served by a host that honors_headers(e.g. Netlify, Cloudflare Pages), or once you translate them into your web server's config. - Privacy policy at
src/pages/privacy.astro— a sensible, cookieless default. Review and adjust it for your jurisdiction before publishing.
| Command | Action |
|---|---|
npm run dev |
Start the dev server |
npm run build |
Build into dist/ (also strips orphaned sources) |
npm run preview |
Preview the production build locally |
npm run gen:brand |
Regenerate the placeholder logo "seal" sources |
npm run gen:icons |
Derive seal/favicons from the seal sources |
npm run gen:placeholders |
Regenerate placeholder photos + the OG image |
npm run build outputs a plain, fully static site to dist/ — deploy that
directory to any static host. There's no server runtime, database, or
host-specific configuration to maintain.
Most static hosts can build straight from GitHub. Push this repository, then point your host at it with these settings:
- Build command:
npm run build - Output / publish directory:
dist - Node version:
22(matches.nvmrc)
Every push then rebuilds and redeploys automatically. This works on Netlify, Vercel, Cloudflare Pages, GitHub Pages (via an action), and similar.
npm run build # → dist/Then publish dist/ however you like — drag-and-drop to a host, rsync to a
server, or sync to an object store (e.g. S3) behind a CDN.
public/_headers carries the Content-Security-Policy and other security headers.
Hosts such as Netlify and Cloudflare Pages apply it automatically; on Nginx,
Apache, or S3/CloudFront, translate those rules into the server/CDN config (see
§7). The site works without them — they're hardening, not a requirement.
Add your domain in your host's dashboard, then set site in astro.config.mjs
to that URL (see §2.6). After deploying, submit
https://your-domain/sitemap-index.xml to Google Search Console and Bing
Webmaster Tools.
src/
├── assets/
│ ├── digital/ # ← Digital gallery images
│ ├── analog/ # ← Analog (film) gallery images
│ ├── calendar/ # ← Calendar images (YYYY-MM.jpg)
│ └── blog/ # ← Blog photos (referenced by <Photo> / <Gallery>)
├── components/ # Sidebar, PhotoGrid, Calendar, Lightbox, Logo, Icon,
│ # Photo, Gallery, ShareButton, PostCard, BaseSchema
├── content/ # ← Blog posts (Markdown / MDX)
│ ├── tech/ # Tech Blog (example: getting-started.md)
│ └── photography/ # Photo Blog (example: a-first-walk.mdx)
├── content.config.ts # ← Blog collections + frontmatter schema
├── data/
│ ├── calendar.ts # ← Calendar entries (month + location)
│ └── galleries.ts # ← Optional per-photo captions for the galleries
├── lib/photos.ts # web-resolution image rendering (MAX_WEB)
├── layouts/ # Layout.astro, BlogPost.astro
├── pages/ # index (Digital), analog, calendar, about, contact, blog/*
├── styles/global.css
└── config.ts # ← name, nav, social links (START HERE)
scripts/
├── generate-brand.mjs # placeholder logo "seal" sources
├── generate-icons.mjs # derives seal + favicons from the sources
├── generate-placeholders.mjs # placeholder photos + OG share image
└── strip-unreferenced-assets.mjs # post-build: removes orphaned originals
The template code is released under the MIT License — use it freely, including for commercial projects.
Once you add your own work, your photographs and writing are yours — MIT covers the template, not your content. The visitor-facing image-licensing page defaults to "all rights reserved"; edit it to suit you.