All agents

Logos · brand colors · canonical company names

BrandOps

How every company logo, brand color, and canonical company name on saastock.com gets resolved, cached, and rendered. Powered by Brandfetch — build-time fetch of icons, symbols, and wordmarks, downloaded to /public/logos and indexed in a typed manifest. Consumed by SpeakerOps (home cards), sponsor sections, and partner pages.

ActiveUpdated 2026-05-21

Data assets

Source files this agent will draw from. Not indexed by search engines — internal working data only until the relevant pages ship.

  • JSON

    Brand manifest (index.json)

    Build-time manifest mapping every featured-speaker company → its canonical domain, cached logo path, logo type/theme, and primary brand color. Generated by scripts/fetch_brand_logos.py. Re-run after editing FEATURED_SLUGS in top-speakers.ts or after a brand updates its logo.

    Download (24 KB)

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:

  1. apps/web/app/speakers/registry.ts — every speaker's latestCompany. ~500–600 companies once de-duplicated.
  2. 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)

  1. Check DOMAIN_OVERRIDES at the top of scripts/fetch_brand_logos.py — used to disambiguate common names (Frontfront.com, ConvertKitkit.com post-rebrand, Roamro.am). Add to this map when the auto-resolved domain is wrong.
  2. Else call GET https://api.brandfetch.io/v2/search/{name} and pick (verified, qualityScore) max.
  3. GET https://api.brandfetch.io/v2/brands/{domain} for the full record.
  4. Pick best logo: type=icon > symbol > logo > other; prefer theme=light (dark logo on light bg); prefer svg > png > webp > jpg.
  5. Download to apps/web/public/logos/<company-slug>.<ext>.
  6. Extract a primary brand color (type=brand preferred, then type=accent, skipping pure black/white).
  7. 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.

SurfaceFileKey used for lookup
Home featured-speakers cardsapps/web/app/home/top-speakers.tsspeaker.latestCompany
Home top-sponsors cardsapps/web/app/page.tsxsponsor.display
/speakers master list cardsapps/web/app/speakers/SpeakersBrowser.tsxspeaker.latestCompany
Master nav speakers dropdownapps/web/components/site-chrome.tsxspeaker.latestCompany
Event-page sponsor rowsapps/web/components/event-page.tsxeventSponsor.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:

  1. Verify by visiting https://api.brandfetch.io/v2/search/{name} — note the domain the top result points at.
  2. If wrong, add an override: "<exactCompanyNameInRegistry>": "<canonical-domain>". Use the exact string from latestCompany in the speaker registry or display/name in the sponsor data — that's the key.
  3. Delete the bad logo file from apps/web/public/logos/.
  4. 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 — never object-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> over next/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=dark variant too and the manifest needs a second logoPathDark field. Not yet shipped.

7. What this agent does NOT own

  • Who appears on the home page or in the nav — that's SpeakerOps (FEATURED_*_SLUGS in top-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.json and per-event data.ts files. 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.