Personal portfolio for Nick Lathrop, a photographer based in Golden, Colorado. Auto-syncs from Instagram every 24 hours via GitHub Actions.
Stack: Astro 5 · Svelte islands · Tailwind v4 · Lenis smooth scroll · GSAP ScrollTrigger · PhotoSwipe lightbox · Sharp + thumbhash · Playwright stealth scraper · Cloudflare R2 · Netlify
git clone <repo>
cd nicklathrop-photography
npm install
npm run dev # http://localhost:4321The site builds with placeholder seed photos so you can iterate without running the scraper. First scrape replaces the seed data.
GitHub Actions cron (24h) ─→ Playwright stealth ─→ images downloaded
│
┌─ R2 (full-res, lightbox) ←───────────┤
│ │
└─ public/thumbs/ (800px webp) ←───────┤
src/data/posts.json (metadata) ←───┘
│
git push ─→ Netlify build hook ─→ rebuild + deploy
R2 holds the original-resolution photos (lightbox). The repo only carries 800px thumbs.
- Create a Cloudflare account, enable R2.
- Create a bucket — e.g.
nicklathrop-photos. - R2 → Manage R2 API Tokens → "Create Token" with Object Read & Write on the bucket. Save
Access Key IDandSecret Access Key. - Bucket → Settings → Public access → connect a custom subdomain (e.g.
photos.nicklathrop.com) or use ther2.devURL. - Note the account ID (top of the R2 dashboard URL).
The scraper logs in as a throwaway IG account. Do not use Nick's real account.
- Create a fresh Instagram account (no profile photo, no bio, just an email).
- Have it follow @ginger.snaps19 + 5–10 random photography accounts so it looks real.
- Log in via an incognito browser window.
- DevTools → Application → Cookies →
https://www.instagram.com - Copy three cookie values:
sessionid,ds_user_id,csrftoken. - Paste into the
IG_SESSION_COOKIEGitHub Actions secret as JSON:
{"sessionid":"...","ds_user_id":"...","csrftoken":"..."}If IG ever flags the burner, repeat with a new account. Real account stays untouched.
- Run
./scripts/deploy-site.shonce. Requiresnetlify-cli(npm i -g netlify-cli && netlify login) andgh(gh auth login). - Script creates the site, links it to GitHub, creates a build hook, saves the hook URL as the
NETLIFY_BUILD_HOOKGitHub Actions secret. - Site goes live at
https://<random>.netlify.app.
In Settings → Secrets and variables → Actions, add:
| Secret | Source |
|---|---|
IG_SESSION_COOKIE |
Step 2 |
R2_ACCOUNT_ID |
Step 1 |
R2_ACCESS_KEY |
Step 1 |
R2_SECRET_KEY |
Step 1 |
R2_BUCKET |
Step 1 |
R2_PUBLIC_HOST |
Step 1 (e.g. https://photos.nicklathrop.com) |
NETLIFY_BUILD_HOOK |
Step 3 (set automatically by deploy-site.sh) |
Actions tab → "Scrape Instagram" → "Run workflow" → leave dry=false → Run. Watch the run; it should commit src/data/posts.json + public/thumbs/*.webp and trigger a Netlify build.
The plan ships v1 on a Netlify subdomain. The Google ranking timeline against LinkedIn starts when the custom domain goes live, not when v1 ships. Run this as soon as Nick has a domain:
./scripts/swap-domain.sh nicklathrop.comThis adds the domain to Netlify, provisions Let's Encrypt, updates the canonical URL in astro.config.mjs, adds a 301 redirect from the old subdomain, commits and pushes, and prints a post-swap checklist.
The site has on-page SEO covered (titles, schema, sitemap, RSS, content depth). What ranking against LinkedIn actually needs from you:
- Buy
nicklathrop.com(or.photography/nicklathropphoto.com). Cloudflare Registrar is at-cost. Single highest-leverage thing. - Run
swap-domain.shonce you've got the domain. - Submit
https://nicklathrop.com/sitemap-index.xmlto Google Search Console. - Same to Bing Webmaster Tools.
- Add
https://nicklathrop.comto your LinkedIn → Contact info → Website. This tells Google the two profiles are the same person and consolidates authority on your site. - Update your Instagram bio link to the new domain.
- Set up a Google Business Profile as "Photographer in Golden, CO". Single biggest local-SEO lift.
- Cross-post to Behance / 500px / Flickr with
nicklathrop.comin the profile bio. Each is a high-DA backlink. - Pitch a guest gallery to a local Denver/Golden community blog or magazine — even one good editorial backlink kills LinkedIn's authority advantage.
- List on photography directories (Local Photographers Network, The Knot if doing weddings, Snappr, Photographer Directory).
- Mark your calendar for 90 days post domain swap: revisit ranking. Typical timeline to outrank LinkedIn for personal name = 3–6 months.
npm run dev # dev server at http://localhost:4321
npm run build # static build to dist/
npm run preview # serve the static build
npm run scrape # run the IG scraper locally (needs IG_SESSION_COOKIE in .env)
npm run scrape:dry # scrape but don't commit anything
npm run thumbs # regenerate thumbnails from tmp/scrape/
npm run verify # screenshot audit + Playwright sanity check (writes verify/*.png)src/data/posts.json is auto-written by the scraper, but four fields are preserved across runs by ID match:
alt— accessibility text (defaults to caption)category— one ofportrait | landscape | street | lifestyle | otherfeatured— boolean, drops the photo into the hero/featured gridnote— manually written ~50-word note that shows up on the post detail page (this is what makes individual posts substantive enough to rank)
Edit the JSON, commit, push. Scraper picks up your edits on the next run.
- Scraping Instagram violates their ToS. Burner account isolates the risk to a throwaway. If IG fingerprints it, swap in a new burner — the site stays online from the last good
posts.json. - IG CDN URLs expire ~24h. The scraper always downloads, never hotlinks.
- Stealth Playwright is not invincible. Plan B is the Behold.so widget (TODO: wire up if scraper fails 2+ times in a row); Plan C is just the existing static
posts.jsoncache. The site never goes blank.
.github/workflows/
scrape-instagram.yml # cron + manual dispatch
scripts/
scrape.mjs # Playwright stealth → tmp/scrape/
make-thumbs.mjs # Sharp → public/thumbs/, thumbhash → manifest
upload-r2.mjs # full-res → Cloudflare R2
verify-screenshots.mjs # Playwright section-by-section audit
deploy-site.sh # Netlify ↔ GitHub auto-link (one-shot)
swap-domain.sh # custom domain swap + 301s + GSC checklist
src/
pages/
index.astro # hero + featured + latest IG + CTA
work/index.astro # full archive with category filter
work/[slug].astro # individual post detail w/ EXIF + Image schema
about.astro # ~400-word bio for SEO depth
denver-photographer.astro # local landing page targeting "Denver photographer" queries
contact.astro # Text-CTA + sidebar
404.astro
rss.xml.ts # RSS feed for SEO freshness signal
components/
Hero.astro, Nav.astro, Footer.astro, Schema.astro, ContactSection.astro
Reveal.svelte # IntersectionObserver-driven fade-up (forwards `class`)
Masonry.svelte, Lightbox.svelte, ScrollPin.svelte, Cursor.svelte, SmoothScroll.svelte
styles/
tokens.css # OKLCH design tokens
app.css # @keyframes + base
data/posts.json # authoritative source of truth (scraper writes, you edit)
lib/ # site config, SEO helpers, posts loader
public/
thumbs/ # 800px webp, git-tracked
robots.txt, _redirects, favicon.svg
astro.config.mjs, tailwind.config.ts, netlify.toml, tsconfig.json
MIT — see LICENSE. Photographs are © Nick Lathrop and not covered by the code license.