SaaStock — MediaOps Playbook
The single reference for how every photo and video shipped on saastock.com is named, captioned, converted, classified, schema-tagged, and cross-linked. Hand this to any developer or content operator building a new event gallery, and the result should be indistinguishable from the Dublin 2023 reference implementation at /europe/2023/gallery.
This playbook extends SearchOps — SearchOps governs the whole site's SEO/AEO/GEO stack; MediaOps governs the image- and video-specific stack inside it.
1. URL Architecture
Every gallery lives under its parent event:
saastock.com/
├── europe/2023/
│ ├── gallery/ ← Photo + video gallery
│ │ ├── llms.txt ← Plain-text manifest for AI engines
│ │ ├── photos/<slug>.webp ← Full-res (1600px max edge, q=82)
│ │ ├── thumbs/<slug>.webp ← Thumb (600px max edge, q=78)
│ │ └── video/
│ │ ├── <slug>.mp4 ← H.264 720p, AAC 96k, faststart
│ │ ├── <slug>.webm ← VP9 720p, Opus 96k
│ │ └── <slug>-poster.webp
Rules:
- One gallery per event. Side events (Startup Day, NightStock, SaaS.City, Welcome Party) are not separate URLs — they're sub-sections inside the parent event's gallery, anchored at
#<side-event-slug>. - File names are descriptive slugs (
expo-vanta-booth.webp), never camera defaults (DSC_0042.jpg). File names are indexed by Google Images.
2. Phase taxonomy (the social-proof problem)
Every photo carries a phase so the page can clearly separate the pre-doors build-out (empty booths, dark mainstage) from the live event (packed crowd, peak expo, panels in motion). Mixing those without context reads as "low attendance" to a casual scroller.
| Phase | Badge text | Colour | Use for |
|---|---|---|---|
live | Live · during the event | Green #0BA86A | Audience seats filled, panel in motion, expo at peak |
setup | Pre-doors · sponsor booth | Amber #C36A1A | Sponsor booth shots captured before attendees arrived |
bts | Build-out | Slate #3F4861 | Rigging, LED-wall alignment, empty stage waiting on chairs |
signage | On-site signage | Pink #E91E8C | Floor plans, partnership boards, future-edition booking |
The page renders in this section order: Live → Construction (merged setup + bts) → On-site signage → Startup Day / other side events. Setup and bts share a single rendered section called "Construction" — the underlying phase distinction stays for badge colours and schema, but readers don't see two near-identical "empty venue" blocks back-to-back.
Hero anchor nav, not a separate section. Section-skip links live inside the hero (right under the hero stat row) as short anchor pills — "Live during the event ↓" and "Construction ↓" — that scroll to the matching id on click. Do not add a standalone "Skip to a section / Live first, build-out clearly labelled" block below the hero — that's redundant. Keep the hero nav minimal (2 pills, not 5); the other sections are reachable by scrolling.
Badge placement: phase badges and side-event badges render below the image, inline at the top of the figcaption — never as an absolute-positioned overlay on top of the photo. The image itself stays clean.
3. Side-event taxonomy (the two-place rule)
When an event has side programs (Startup Day, NightStock, SaaS.City, Welcome Party, sponsor dinners), surface them in two places:
-
/europe/<year>/gallery— every side-event photo carriessideEvent: { slug, label, venueName? }ingallery-data.ts. The page renders them in their own#<slug>sub-section.ImageObject.contentLocationpoints at the side-event venue (e.g. Zendesk Dublin for Startup Day), not the main venue (RDS Simmonscourt).ImageObject.aboutincludes a SubEvent referencing the parent Event's@id. -
/europe/<year>— a matching entry inrelatedEvents[]in the event'sdata.ts, withhref: "/europe/<year>/gallery#<slug>"andimage:pointing at one feature photo. This renders as a clickable card in the "Side Events and Experiences" section.
The lightbox doesn't auto-open on section anchors (only on photo-slug hashes), so clicking the side-events card scrolls the gallery to the correct sub-section.
4. Asset pipeline
Photos
- Source: any RAW or JPEG, any resolution.
- Output:
cwebp -q 82 -m 6 -metadata none -resize 1600 0for full,-q 78 -m 6 -metadata none -resize 600 0for thumb. - EXIF is stripped (
-metadata none). - Native aspect ratios are preserved in the file (so the lightbox shows the full frame); we store
width+heightin the data file to prevent CLS. - Grid display ratio is uniform — every card in the gallery grid renders at
aspect-ratio: 4 / 3withobject-fit: cover. This prevents portrait + landscape shots from creating jagged rows with extra white space. The native aspect only matters in the lightbox, which sizes to the image. Pick the grid ratio per project (4:3 is the SaaStock default; square or 16:10 are reasonable alternatives) but apply it uniformly across every card on the same page. - The full WebP is what
<a href>andImageObject.contentUrlpoint at. The thumb is what<img src>andthumbnailUrlpoint at.srcSetlists both.
Videos
- Source: any
.mov,.mp4,.webm. - Output:
ffmpeg -c:v libx264 -preset slow -crf 24 -pix_fmt yuv420p -c:a aac -b:a 96k -movflags +faststart→<slug>.mp4(Safari-safe).ffmpeg -c:v libvpx-vp9 -crf 34 -b:v 0 -row-mt 1 -tile-columns 2 -c:a libopus -b:a 96k→<slug>.webm(Chrome/Firefox).- Scale: 720p (
scale=1280:-2landscape,scale=-2:1280portrait). - Poster: extract a frame at 1s, save as
<slug>-poster.webpat q=80.
preload="none"on every<video>element except the hero/lead video.
Portrait video grids — always 3-up
Multiple portrait (9:16) videos shown together render in a 3-column grid on desktop / tablet, never 2-up. A 2-up portrait grid produces clips that are way too tall, dominate the viewport, and break the page rhythm. 3-up keeps each clip at a sensible scale and gets more content above the fold.
Why: Caught on /europe/2018/gallery — six portrait clips ran as a 2-up grid and read as "way too big." 3-up is the new floor.
How to apply:
- Tailwind:
grid grid-cols-3 gap-5 max-[640px]:grid-cols-1for a portrait-video strip (drop straight to 1-up on narrow mobile; never go through a 2-up step). - Same rule for portrait-photo strips when they're shown as a dedicated "video strip"-style section. The main multi-column gallery grid already uses uniform
4:3cards (see §4 Photos), which is unaffected. - Landscape videos can still render 2-up if the page only has two of them — landscape doesn't have the same vertical-dominance problem. But if you ever have ≥3 portrait clips, default to 3-up.
Gallery hero must be a photo, not a video (biggest stage + biggest audience)
The lead "feature" slot on every gallery page is a single landscape photo — the one frame that shows the biggest stage and the biggest audience in this gallery. Even when a landscape video is available, the photo beats the clip in the lead slot. The lead is a first impression; a still packed-house frame conveys "this is a serious event" instantly, while a video makes the reader wait for motion to interpret.
Why: First build of /europe/2018/gallery used the landscape LATKA Magazine seat-drop video (panning across empty chairs) as the lead. Even though it satisfied the no-portrait-hero rule, an empty-seats clip is the wrong first impression for "the first SaaStock to break 2,000 attendees." Swapped to the Rocket Fuel Stage balcony photo — packed audience, curved LED set, full venue context — and the page now opens with the right energy.
How to apply:
- Pick the single landscape photo in the gallery that maximises stage size + audience density + venue context in one frame. Balcony / overhead shots usually win because they show the whole audience at once.
- Render it as a large
<figure>directly under the press-card row (same prominence the lead-video figure used to have). - The lead photo gets
loading="eager"+fetchPriority="high"(it's the page's LCP candidate). - Click on the lead photo opens it at index 0 in the lightbox; the photo is excluded from the "Live" grid below so it isn't duplicated.
- Videos — even strong landscape ones — move into the "More from the floor" grid below.
OG image and event-page hero should point at the same landscape photo (or a similarly-framed one from the gallery), so social previews and the event-page hero match the gallery's first impression.
Hero / lead-media orientation (no vertical hero, ever)
Never use a portrait/vertical (9:16) video or image as the page's lead/hero element on a wide-page layout. A 9:16 asset inside a wide container produces large dead bars on the left and right — bad first impression, wasted screen real-estate, and the actual content gets visually shrunk into a narrow centre column.
Why: Galleries and feature pages open on desktop/tablet wide layouts. The lead/hero slot has to fill that width. A vertical asset can fill the height but never the width — the result is a centred frame with empty rails on either side. This was caught on the first pass of /europe/2018/gallery, where a 9:16 dome clip ran as the lead and produced exactly that effect.
How to apply:
- The lead media on any gallery, recap, or feature page must be landscape (≥ 16:9) — or square (1:1) only when no landscape asset exists.
- Portrait assets belong inside the multi-column grid (where the uniform
4:3card crop normalises them) or inside the lightbox after a click. Never at the top of the page on their own. - If the strongest "live" moment only exists in portrait, do not force it into the lead. Pick the next-best landscape clip — even a pre-doors / build-out / signage frame — and surface the portrait one inside the grid. Same rule for event-page heroes and landing-page hero bands.
- The lead-video figure's frame uses
aspectRatio: "16 / 9"(or wider). If your only candidate is portrait, change the lead — do not change the container aspect ratio to match the portrait asset.
File naming
Pattern: <section>-<subject>.webp. Section labels: stage, panel, audience, expo, team, venue, branding, bts, registration, networking, mainstage, <side-event-slug>. Subject is a descriptive slug (bootstrap-audience, founderpath-booth-wide).
5. Alt text and captions
Every alt follows the pattern:
<Context label> at SaaStock <Series> <Year> — <specific description of what's in the frame>
Context labels:
- "Live during SaaStock Europe 2023"
- "Before doors at SaaStock Europe 2023"
- "Behind the scenes at SaaStock Europe 2023"
- "On-site signage at SaaStock Europe 2023"
- "Live at SaaStock Europe 2023 Startup Day" ← side events use this form
Rules:
- Never start with "Image of" or "Photo of" — screen readers already announce the element type.
- Include company names visible in the frame — these are the keywords people search for.
- Include venue name (e.g. RDS Simmonscourt, Zendesk Dublin) — geo signal.
- Include city — local SEO.
- No duplicates across images on the same page.
- Go longer than 125 chars if needed for accuracy. Google indexes the full
alttext.
Captions (<figcaption>) are visible, not sr-only. They start with the same context label as the alt, then add one sentence of editorial detail the alt can't carry. Link sponsor names back to the event-page sponsor section (/europe/<year>#sponsors). Captions are crawled as regular text and carry significant weight.
Do not duplicate the alt into a title attribute on the same element. The playbook checklist explicitly removes redundant tooltips that don't help SEO.
6. Schema graph (one @graph, three top-level types)
Every gallery page emits exactly one <script type="application/ld+json"> block carrying a schema.org/@graph with three top-level entries:
BreadcrumbList— Events → <Event short name> → Photo gallery.CollectionPage—@id: <pageUrl>#gallery,isPartOf: { @id: <eventUrl>#event },primaryImageOfPage: { @id: <pageUrl>#<heroSlug> }, pluskeywords,inLanguage, and aSpeakableSpecificationpointing at the "About" section CSS selector.ItemList—numberOfItems: <total>,itemListOrder: ItemListOrderAscending, with oneListItemper photo and video in the visual order they appear on the page. EachListItem.itemis a fully-populatedImageObjectorVideoObject.
Cross-page entity bridge: the main event page (/europe/<year>/page.tsx) must carry @id: <eventUrl>#event on its Event schema. The gallery uses that @id to link every image to the event in the knowledge graph.
ImageObject required fields
@type: ImageObject@id: <pageUrl>#<slug>contentUrl,thumbnailUrl,name(= alt),description(= caption)width,heightdateCreated— ISO date. Live/signage = mid-event day. Setup/bts = day before. Use the specific day where known.representativeOfPage: trueon the hero image, omit on the rest.isPartOf: { @id: <eventUrl>#event }creator= SaaStock Organization.copyrightHolder= SaaStock.license= site root or/terms/.creditText: "SaaStock".contentLocation— Place + PostalAddress. Side-event photos point at the side-event venue (e.g. Zendesk Dublin), not the main venue.about— when the photo features a sponsor booth, add anOrganizationentry pointing at the sponsor. When the photo is from a side event, also add anEvententry referencing the parent@idviasuperEvent.
VideoObject required fields
@type: VideoObject,@id: <pageUrl>#<slug>name(= alt),description(= caption),thumbnailUrl(the poster),contentUrl(the MP4)embedUrl(= page URL),uploadDate(event end date),dateCreated(the day filmed)duration— ISO 8601 (PT28S)creator,copyrightHolder,license,creditText,isPartOf,contentLocation— same asImageObject
Skipped on purpose
- WebP
<picture>with JPEG fallback — cargo-cult in 2026. WebP is supported by every modern browser and crawler. Don't double storage. - Separate gallery-sitemap.xml — use
<image:image>extensions inside the main sitemap entry for the gallery URL. One less file. - Per-photo individual route pages (
/gallery/<slug>/) — overhead for marginal long-tail; revisit if traffic data justifies.
7. Lightbox
Every gallery is rendered with a full-screen lightbox. Click delegation: anchors carrying data-lightbox-index are intercepted by a client component (lightbox.tsx). Modifier-clicks (cmd/ctrl/shift, middle-click) bypass the lightbox so "open in new tab" still works.
Behaviour:
- Centered media (max-height
calc(100vh - 180px), max-widthmin(1280px, 92vw)), with aspect-ratio preserved. - Arrow keys (and on-screen chevron buttons) traverse all items in visual page order — lead video → live photos → setup → bts → signage → side-event photos → other videos. Wraps around the ends.
- Home / End / PageUp / PageDown supported.
- Esc closes. Clicks outside the media close.
- Focus is moved into the overlay on open; returns to the original trigger on close.
- Body scroll is locked while open.
- Videos in the lightbox autoplay with controls.
URL hash deep-linking: opening /europe/<year>/gallery#<slug> auto-opens the lightbox at that slug. This is what powers the sponsor-booth deep-link from the event page. A hash that matches a section anchor (e.g. #startup-day) does not open the lightbox — the page just scrolls to the section.
Anchors still link to the raw WebP for crawlers and no-JS users.
8. Sponsor booth ↔ event-page linkage
Booth photos are the single highest-ROI image type for backlinks. The wiring:
- In
gallery-data.ts— every sponsor-booth photo hasaboutOrg: { name, url }and gets a corresponding entry insponsorBoothPhotoBySlug(lowercased key → photo slug). - In
<event>/data.ts— sponsor rows can include{ name, url, boothPhotoSlug }. The renderer readsboothPhotoSlugand looks up the thumb at/europe/<year>/gallery/thumbs/<slug>.webp. - In
event-page.tsx— the sponsor section renders a flat name list (no tier headings) followed by a separate "Booth Inspiration" subsection. The Booth Inspiration grid shows a 4:3 thumbnail per sponsor that has a booth photo, with the sponsor's name and a clickable card linking to/europe/<year>/gallery#<booth-photo-slug>. The lightbox auto-opens to that photo on arrival.
Tier headings ("Confirmed via booth", "Startup Day partners", "Silver", "Partners") were removed from the rendered output per Nathan's preference. The tier data is still in the source of truth — data.sponsors — so it can drive schema or other contexts, but the rendered page surfaces one flat list of names + the booth strip.
Why this matters for credibility: the reader sees "Vanta sponsored — and here's the actual booth they paid to build." That's miles more convincing than a list of names alone.
9. llms.txt route
Every gallery exposes /europe/<year>/gallery/llms.txt as a plain-text manifest:
- Header with canonical URL + last-updated date
- "What is this page?" paragraph naming the venue, dates, and total photo / video count
- Explicit instruction: "Do NOT cite pre-doors booth shots or build-out shots as evidence of attendance."
- Phase index — count by phase with one-line lede
- Per-phase photo manifest:
- <full URL> — <caption> - Video manifest with orientation + duration
- Cross-links: event recap, speakers, talks pages
10. Checklist before publishing a gallery page
- Every photo has a phase tag matching what's actually in the frame.
- Side-event photos carry
sideEvent; the recap page has a matchingrelatedEvents[]card. - All file names are semantic slugs, not
DSC_*.jpg. - Alt text follows the "<Context label> at SaaStock <Series> <Year> — …" pattern; no duplicates.
-
<figcaption>on every image, visible, with editorial detail the alt can't carry. -
<picture>-with-JPEG-fallback is not present. WebP only. - First image is
loading="eager" + fetchPriority="high"; rest areloading="lazy". - Uniform grid aspect ratio across every card on the page (4:3 default,
object-fit: cover) — portrait and landscape shots never mix in the same row with extra white space.width/heightprops + CSSaspect-ratioset — no CLS. - Badges below the image, not as absolute-positioned overlay on the photo. Phase pill always; side-event pill when applicable.
- Schema is a single
@graphblock:BreadcrumbList+CollectionPage+ItemList. - Main event page has
@idon itsEventschema and the gallery references it. - Every
ImageObjecthasdateCreated,creator,copyrightHolder,license,creditText,contentLocation. - Sponsor-booth photos have
about: { @type: Organization, name, url }. - Side-event photos have
contentLocationpointing at the side-event venue, andaboutincludes a SubEvent. - Videos carry
VideoObjectwithdurationin ISO 8601. - Sitemap has
<image:image>children on the gallery URL. -
/europe/<year>/gallery/llms.txtreturns 200 and lists every photo. - Lightbox: arrow keys cycle all items in visual order, Esc closes, modifier-clicks pass through.
- URL hash
#<slug>auto-opens the lightbox;#<section-id>scrolls only. - Sponsor section is flat (no tier headings) with a "Booth Inspiration" strip beneath listing every sponsor with a booth photo.
- Recap page's "Side Events and Experiences" section has a card for every side event, linking to
gallery#<slug>. - Press / high-res CTA scoped to booths, venue, build-out — not audience candids.
- No
title={alt}redundant tooltips.
11. Reference implementation
The Dublin 2023 build is the canonical reference:
- Page:
apps/web/app/europe/2023/gallery/page.tsx - Data:
apps/web/app/europe/2023/gallery/gallery-data.ts - Lightbox:
apps/web/app/europe/2023/gallery/lightbox.tsx - llms.txt:
apps/web/app/europe/2023/gallery/llms.txt/route.ts - Sponsor cross-link:
apps/web/app/europe/2023/data.ts(sponsors[].boothPhotoSlug) +apps/web/components/event-page.tsx(renderer) - Side-event card:
apps/web/app/europe/2023/data.ts(relatedEvents[].href = "/europe/2023/gallery#startup-day")
When in doubt, clone Dublin 2023's structure and substitute the new event's data.
12. Local chapters (SaaStock Local)
City chapters publish at /local/<city-slug>/. Per-event galleries publish at
/local/<city-slug>/gallery/ — every URL/phase/schema rule in this playbook
applies unchanged. Where flagship-event galleries cover one event with many
side events, a local-chapter gallery is the inverse: one chapter URL with
many events across years, surfaced as time-anchored sub-sections inside
the gallery.
URL architecture for local chapters
saastock.com/local/<city-slug>/
├── gallery/ ← One gallery per city (all events combined)
│ ├── llms.txt
│ ├── photos/<slug>.webp ← 1600px, q=82
│ ├── thumbs/<slug>.webp ← 600px, q=78
│ └── (video/ if present)
Sub-sections: photos for a specific evening are anchored at
#<event-slug> (e.g. #q2-2024, #september-19-2024) — same shape as the
side-event anchors on flagship galleries, swapping side-event for event-date.
For a chapter with only one documented event, the gallery can ship without
sub-sections and use the Live → Construction → Signage section order
directly (Amsterdam Q2 2024 is the reference).
Two-place rule for local events
Same wiring as flagship side events, applied to chapter events instead:
-
/local/<city>/gallery— every photo of a chapter event carries the event date (used inImageObject.dateCreated) and the venue (used inImageObject.contentLocation). Photos hosted by a third-party SaaS office (e.g. Personio's Amsterdam office for Amsterdam Q2 2024) pointcontentLocationat the host venue — that's the geo-signal Google Images indexes. -
/local/<city>— every event inregistry.tsthat has a gallery carriesgalleryUrl: "/local/<city>/gallery"(optionally with a#<event-slug>fragment when the gallery is multi-event) and agalleryHeroThumbpointing at one feature photo. The chapter page renders this as a clickable card under the events-history table and adds animagefield on the event's schema.org node.
Hero photo selection for chapter galleries
The gallery's hero (the photo that gets representativeOfPage: true on its
ImageObject and is the OG image for /local/<city>/gallery) is the most
representative live shot:
- A handshake, a featured speaker on stage, or a crowd-and-stage wide. Not the empty venue, not a sponsor-logo close-up.
Chapter-page hero photo (LocalCity.chapterHeroPhoto) — full-bleed header
The chapter page (/local/<city>/) opens with a full-bleed packed-room
hero that mirrors the SaaStock.com homepage. The chapterHeroPhoto.src
is absolute-positioned behind a dark navy gradient
(rgba(22,14,65,0.92) → 0.18), with the white H1, SaaStock Local
eyebrow, and openingParagraph overlaid in the foreground. A back-link
to /local/ sits top-left in white; a "Pictured: …" credit sits
bottom-right and, when deepLinkUrl is set, links into the gallery
lightbox at that exact slug.
Page-order rationale: the first thing a visitor lands on must answer "is this a real community?" — and a packed-room photo answers that in one second. The events-history table and AEO/SEO content sit below the trust-building content (hero → leaders → highlight video → speakers → photo strip).
Picking this image well is what makes the chapter page feel like a community, not a directory entry — so the rule is deliberate:
Pick the single photo that shows the biggest audience from a delivered event. Specifically:
- The most heads visible in frame (≥20 ideally; the back rows extending into the room beats a tight close-up of the front row).
- The stage and speakers visible too — so the photo reads as "real event happened here" not "stock photo of a crowd in a room".
- At least one geo-anchor in frame when available: a hoodie/sign/window with a recognisable city marker (e.g. "Rusty Gold Motorshop Amsterdam", a London skyline through the window, the Lisbon hillside) — these turn a generic audience shot into one Google Images can place geographically.
- The host venue's branding or wordmark also acceptable when no city marker is visible.
- Clean composition: no plants or attendees' shoulders bisecting the frame.
What to avoid:
- Empty room or sparsely-seated shots — these read as "low attendance". Always pick from the live phase.
- Close-up portraits — they don't show scale.
- Heavily backlit or low-light shots — the hero needs to render cleanly at
fetchPriority="high"without a colour-cast or graininess.
The photo is the value of chapterHeroPhoto.src in registry.ts and
deepLinkUrl should open the gallery lightbox at that exact slug.
Eager-loaded (loading="eager" + fetchPriority="high"), rendered
object-cover inside a min-h-[min(72vh,680px)] hero section. Landscape
and portrait sources both work, but landscape is preferred because the H1
sits in the lower-left. This image is the canonical "social proof" asset
for the chapter — if the chapter has multiple delivered events, pick the
biggest-audience shot from any of them and caption it with the event date
so the photo is honestly dated.
Chapters without a chapterHeroPhoto yet render the hero as a navy
block with the gradient overlay and the white H1/lede — graceful
degradation, no broken layout. Ship chapterHeroPhoto in the same PR
that processes the first photo gallery for the chapter.
Chapter leads are always Featured Speakers
The Featured Speakers grid is rendered from a merged list — leaders
prepended to speakers — with leader cards getting a "Chapter lead ·
SaaStock Local <City>" subtext instead of the "Spoke at SaaStock Local
<City>" used for invited speakers. This is intentional duplicate placement:
the leader already has a card up top, and they appear again as a speaker
card below. Reasoning:
- Leaders run the show at every chapter event — they're on stage by definition, so excluding them from "featured speakers" would be inaccurate.
- The duplicate placement compounds the lead's visibility on the page, which is the single biggest lever for keeping leads engaged and hosting future events. (Per Nathan: "boosts their energy and gets them excited about hosting future events.")
To wire this in registry.ts, add a photo block to the leader entry —
{ src, alt, deepLinkUrl } — pointing at a gallery thumb where the leader
is recognisably on stage or running the room (e.g. opening the welcome,
introducing a panel, holding the mic). Avoid candid social shots — the
leader photo should mirror the energy of the speaker photos beside it.
If a leader is also listed in speakers (e.g. a co-host who later spoke
on stage), the renderer dedupes by name so they only show up once — the
leader entry wins because the "Chapter lead" subtext outranks the
"Spoke at" subtext.
Hub-page card image (LocalCity.cardImage)
The /local/ hub renders every published chapter in a "Featured chapters"
grid. The whole card — image, name, country, leader, events-hosted count —
is one <Link href="/local/<slug>/">. Every Phase-1 chapter that has a
delivered-event gallery must ship a cardImage — a text-only card on the
hub reads as "chapter doesn't exist" to a casual scroller.
Selection rules (similar to chapterHeroPhoto, but tighter crop because
the card is 4:3 not 3:2):
- Reuse the chapter hero photo's thumb where possible — same shot, same framing, no extra curation overhead.
- Live phase only; empty-room and pre-doors shots are disqualified.
- Must read at 320px wide. Faces / a panel slide / a packed room beats a wide skyline.
- Avoid backlit silhouettes — the card sits on white and contrast collapses.
Wiring:
cardImage: {
src: "/local/<slug>/gallery/thumbs/<chosen-slug>.webp",
alt: "Live at SaaStock Local <City> <Year> — <city + venue/moment>"
}
Renders aspectRatio: 4 / 3, object-fit: cover, loading="lazy". No
nested <a>s — the parent <Link> already carries the click. Chapters
without a gallery yet ship without cardImage and render text-only; add
the field in the same PR that processes the first photo gallery.
CTA block + WhatsApp sponsor CTA + AI Growth Summit Local rebrand
Brand transition language. SaaStock Local is being rebranded to AI Growth Summit Local in 2026. The H1 stays "SaaStock Local <City>" (SEO-anchored), but the Attend CTA refers to "AI Growth Summit Local <City> (formerly SaaStock Local)" — that's how visitors land on the new brand without losing the chapter's canonical name. Never describe chapter events as "free" — they are paid events; any older copy that implied free attendance must be rewritten.
Attend / Lead-or-sponsor CTAs are wired off LocalCity.nextEventUrl
(Luma/Tito/etc.). When set:
- Attend body: "Join the next AI Growth Summit Local <City> event (formerly SaaStock Local)."
- Lead-or-sponsor body: "If you want to lead or sponsor, the best way to do it is to come to the event and talk to us in person — or message Nathan directly on WhatsApp."
- Attend CTA: "Click here to attend →" →
nextEventUrl(target="_blank" rel="noopener noreferrer"). - Lead-or-sponsor CTAs: the same "Click here to attend →" AND a green WhatsApp button beside it.
When nextEventUrl is unset, the Attend block falls back to
"Get notified →" /subscribe/ but the Lead-or-sponsor block still surfaces
the green WhatsApp button as the primary CTA — sponsors don't wait for a
chapter to have a Luma listing.
WhatsApp button (WHATSAPP_URL constant, single top-of-file source
of truth — currently https://wa.link/y82o72 resolving to Nathan's
US +1 703-431-2709). Surfaces in two places:
- Leader + WhatsApp paired row directly under the hero — 2-column 50/50 grid. Left cell holds the city-leader card(s); right cell is the pale-background WhatsApp sponsor card with the one-line pitch and a green WhatsApp button anchored bottom-left. Stacks to 1 column under 720px. The pairing surfaces "who runs this chapter" and "how to sponsor" in the same eye-line, so the sponsorship CTA has immediate context (a sponsor sees a real human running the chapter, not a faceless contact form).
- Lead-or-sponsor CTA block — green button beside "Click here to attend".
Both placements link to the same URL. Green is the WhatsApp brand
(#25D366 → hover #1ebe5b), always target="_blank". Routing changes
are a one-line constant edit — no per-city data work.
Sponsors: per-event row + all-time social-proof wall
Each LocalEvent carries a sponsors: string[] of company names. The
chapter page renders these in two complementary places:
-
Per-event — a Sponsors column inside the events-history table. Each event row gets a wrap of pale pill chips, one per company in
LocalEvent.sponsors. Empty events render an em-dash. -
All-time — a standalone "All-time sponsors" section above the CTAs, aggregating every sponsor that has ever sponsored a chapter event. The heading reads "Thank you to our sponsors." — gratitude, not a counter (e.g. "3 companies have backed…" reads as small while "Thank you to our sponsors" reads as warm at any list length). Wall sorting: most repeat appearances first → most-recent year → alphabetical. Each chip carries the sponsor name + a year-tag showing every year they backed an event + an
×Nmultiplier when they appeared more than once.
The point of the all-time wall is social proof that compounds: the
list only grows over time. Three sponsors at year one becomes ten at
year three. The longer the wall, the harder it is for a new potential
sponsor to say no. The wall is purely derived from
LocalEvent.sponsors data — adding a sponsor to a new event row
automatically updates the wall.
When the wall reaches ~20+ sponsors and reads cluttered as text-only
chips, upgrade to a logo wall (separate work — SVG logos under
public/local/<slug>/sponsors/<slug>.svg and a renderer change). For
chapters with 4–15 sponsors, text chips read cleaner than logos and
ship faster.
Per-event highlight reels (LocalEvent.highlightVideo)
When an event has a 60–120s highlight reel, drop it under
apps/web/public/local/<slug>/gallery/video/<event-slug>.{mp4,webm} with a
matching <event-slug>-poster.webp (1600px, q=82, frame from the live
phase). Wire it through the matching LocalEvent.highlightVideo block:
{
...,
highlightVideo: {
src: "/local/<slug>/gallery/video/<event-slug>.mp4",
srcWebm: "/local/<slug>/gallery/video/<event-slug>.webm", // optional
poster: "/local/<slug>/gallery/video/<event-slug>-poster.webp",
alt: "<screen-reader / og:video description>",
caption: "<editorial one-liner under the player>",
duration: "1:24" // optional, "M:SS" or "H:MM:SS"
}
}
The chapter page renders a "Highlight reels" section directly under the
events-history table — one <video controls preload="none" poster=...> card
per event with video, in a repeat(auto-fit, minmax(360px, 1fr)) grid at
16:9. The section only renders when at least one event has video. Never
autoplay; the play button is the CTA.
Reel must be a highlight, not a full talk. Full talk uploads belong on
YouTube and ship as their own /talks/<slug> pages — they are not the
same artifact. Cap MP4 at ~10–15 MB; bigger reels go to a CDN/YouTube and
embed via a separate workflow.
The renderer emits a VideoObject on the event's schema.org
Event.subjectOf — contentUrl, thumbnailUrl, uploadDate (= event
date), and ISO-8601 duration (1:24 → PT1M24S). Don't add the video
to the gallery's existing ItemList schema; reels are an event artifact,
not a photo.
Reference: Amsterdam Q2 2024
- Page:
apps/web/app/local/amsterdam/gallery/page.tsx - Data:
apps/web/app/local/amsterdam/gallery/gallery-data.ts - Lightbox:
apps/web/app/local/amsterdam/gallery/lightbox.tsx(clone of the Dublin 2023 lightbox; refactor to a shared component once a 3rd chapter ships) - llms.txt:
apps/web/app/local/amsterdam/gallery/llms.txt/route.ts - City-page cross-link:
apps/web/app/local/registry.ts(LocalEvent.galleryUrl+LocalEvent.galleryHeroThumb) +apps/web/app/local/[slug]/page.tsx(Gallery column + card strip). - Hub card image:
LocalCity.cardImageon the Amsterdam registry entry, pointing ataudience-wide-from-back.webp(same shot's thumb aschapterHeroPhoto).
49 photos, hosted at Personio Amsterdam, April 25, 2024. Two operator talks
(Ferdinand Goetzen / Joran Hofman; Koen Stam / Lepaya) plus reception, audience,
and a SaaStock Dublin ticket giveaway. No side events, no videos. Hero is
panel-ferdinand-joran-handshake (live, end-of-talk handshake — the single
shot that signals "this event happened" without any framing).
