SaaStock — BrandOps Playbook
The single reference for how every company logo, brand color, and canonical company name on saastock.com gets resolved, cached, and rendered. Powered by Brandfetch. One-shot enrichment script crawls every speaker and sponsor in the site data, caches a logo per brand, and feeds every consuming surface from a single manifest.
This playbook extends SearchOps (page-level SEO/AEO) and is consumed by SpeakerOps (which speakers render on home cards) and the sponsor-section conventions on every event page.
1. The one-shot enrichment rule
Whenever a new speaker or a new sponsor is added to the site data, re-run scripts/fetch_brand_logos.py and the site is fully logo-enriched in one pass. The script is idempotent — only new (uncached) companies are fetched — so it is safe to re-run on every PR that touches speakers or sponsors.
python3 scripts/fetch_brand_logos.py # full pass
python3 scripts/fetch_brand_logos.py --limit 20 # probe with a small slice
python3 scripts/fetch_brand_logos.py --only intercom # retry one
The script is the only place that talks to Brandfetch. Renderers never call the API at runtime.
2. What the script crawls
The script collects unique company names from every "source of truth" on the site, in this priority order:
apps/web/app/speakers/registry.ts— every speaker'slatestCompany. ~500–600 companies once de-duplicated.apps/web/app/home/summary.json#topSponsors[].display— the 16+ recurring sponsors shown on the home page.
When you add a new event data.ts with sponsor names that aren't otherwise in the registry, also add their names here (the summary feed) so they get enriched. Sponsor names that DO appear in the registry (because someone from that company has spoken) are already covered automatically — that's the common case.
If a name appears in multiple sources, the FIRST source wins for the source-tag in the manifest, but the logo is the same either way.
3. The fetch pipeline (per company)
- Check
DOMAIN_OVERRIDESat the top ofscripts/fetch_brand_logos.py— used to disambiguate common names (Front→front.com,ConvertKit→kit.compost-rebrand,Roam→ro.am). Add to this map when the auto-resolved domain is wrong. - Else call
GET https://api.brandfetch.io/v2/search/{name}and pick(verified, qualityScore)max. GET https://api.brandfetch.io/v2/brands/{domain}for the full record.- Pick best logo:
type=icon>symbol>logo>other; prefertheme=light(dark logo on light bg); prefersvg>png>webp>jpg. - Download to
apps/web/public/logos/<company-slug>.<ext>. - Extract a primary brand color (
type=brandpreferred, thentype=accent, skipping pure black/white). - Append to
apps/web/generated/brands/index.json.
The script throttles to ~8 requests/second via a 120 ms sleep between calls so we don't trip Brandfetch's rate limiter on full passes.
The Brandfetch API key lives in apps/web/.env.local as BRANDFETCH_API_KEY (gitignored). Rotate via the Brandfetch dashboard and update the local file; CI/Vercel reads from the same env var name.
4. Where logos render automatically
Every surface below reads from apps/web/generated/brands/index.json via a one-line lookup. Running the script enriches all of them simultaneously.
| Surface | File | Key used for lookup |
|---|---|---|
| Home featured-speakers cards | apps/web/app/home/top-speakers.ts | speaker.latestCompany |
| Home top-sponsors cards | apps/web/app/page.tsx | sponsor.display |
| /speakers master list cards | apps/web/app/speakers/SpeakersBrowser.tsx | speaker.latestCompany |
| Master nav speakers dropdown | apps/web/components/site-chrome.tsx | speaker.latestCompany |
| Event-page sponsor rows | apps/web/components/event-page.tsx | eventSponsor.name |
When you wire up a new surface that should show logos, follow Section 6's consumer pattern.
5. Domain overrides (the disambiguation rule)
The Search API gets it right ~70% of the time. The other 30% are ambiguous names ("Front" — many companies share it), rebrands ("ConvertKit" → kit.com), and short product names that don't match the company URL ("Roam" → ro.am). For these, we pin the canonical domain in DOMAIN_OVERRIDES at the top of scripts/fetch_brand_logos.py.
When you spot a wrong logo on the site:
- Verify by visiting
https://api.brandfetch.io/v2/search/{name}— note the domain the top result points at. - If wrong, add an override:
"<exactCompanyNameInRegistry>": "<canonical-domain>". Use the exact string fromlatestCompanyin the speaker registry ordisplay/namein the sponsor data — that's the key. - Delete the bad logo file from
apps/web/public/logos/. - Re-run
scripts/fetch_brand_logos.py. The override + missing file together force a fresh fetch.
6. Consumer pattern (use this when wiring up a new surface)
In any .ts/.tsx file:
import brandManifest from "@/generated/brands/index.json";
// or, depending on relative depth:
import brandManifest from "../../generated/brands/index.json";
const brands = brandManifest as Record<string, { logoPath: string; primaryColor: string | null }>;
const logoPath = brands[company]?.logoPath; // "/logos/intercom.png" or undefined
Render:
{logoPath ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={logoPath}
alt="" // decorative when name is right next to it; else `${company} logo`
aria-hidden // ditto — drop when alt is meaningful
className="h-9 w-9 object-contain"
loading="lazy"
decoding="async"
/>
) : null}
Rules:
- Always use
object-contain— neverobject-cover. Square crop destroys wordmarks. - Always lazy-load below the fold (
loading="lazy" decoding="async"). Logos are not LCP candidates. - Never use the Brandfetch CDN URL directly in JSX. Always go through the manifest and the cached file in
/public/logos/. The CDN URL contains a cache-bust query param that changes on rebuild and breaks our build-time HTTP cache. - Raw
<img>overnext/image— logos are tiny (<20 KB), already optimised at fetch time, and the Next image loader adds more overhead than it saves. - Light backgrounds only, for now. If we add dark sections that need light logos, the script needs to fetch the
theme=darkvariant too and the manifest needs a secondlogoPathDarkfield. Not yet shipped.
7. What this agent does NOT own
- Who appears on the home page or in the nav — that's SpeakerOps (
FEATURED_*_SLUGSintop-speakers.ts). - The speaker registry itself — that's SearchOps (
scripts/generate_speaker_registry.py). - Photo / video file naming and gallery schema — that's MediaOps. Logos are flat brand marks, not editorial photography; different domain.
- Sponsor tier mapping or which sponsors appear at which level — sponsor curation lives in
apps/web/app/home/summary.jsonand per-eventdata.tsfiles. BrandOps provides the logo file path; the renderer decides which sponsors to show.
If you change which speakers or sponsors appear anywhere on the site, re-run scripts/fetch_brand_logos.py so any new companies get their logos cached.
