Skip to main content

Logo Serving & Versioning

Partner logos (up to 256 KB PNG each, stored as a Buffer on partners[].organizationLogo) are never sent to the client inline. Client payloads carry only a short content hash — the logo version — and browsers fetch the bytes once from a dedicated route handler that serves them with aggressive HTTP caching.

Why

Before this design, toPlain(organization) serialized every partner's logo into the OrganizationProvider props on every page load — as a {type:'Buffer',data:[...]} integer array, roughly 4× larger than the binary. A tenant with a handful of partner logos shipped megabytes of redundant JSON per navigation, and the same logo was re-embedded as a base64 data: URL in every chip that displayed it. next/image cannot help: data: URLs are automatically excluded from image optimization.

With the versioned URL scheme, a logo crosses the wire once per browser, per logo change.

Architecture

MongoDB (partners[].organizationLogo: Buffer)

├── server components / layouts
│ toPlain() → organizationLogoStrip() / partnerLogoStrip()
│ └── client sees: organizationLogoVersion: "a1b2c3d4e5f60789" (or null)

└── GET /api/logo/[organizationId]?v=<version>
tenant from Host header → partner lookup → PNG bytes
Cache-Control: public, max-age=31536000, immutable
PieceLocationRole
fnv1a, fnv1a64src/lib/utils/fnv1a.tsDependency-free non-cryptographic hash over a byte stream
logoVersionGetsrc/lib/utils/logoVersion.ts16-hex-char content hash of a logo in any serialized shape
partnerLogoStrip / organizationLogoStripsrc/lib/utils/logoVersion.tsReplace organizationLogo with organizationLogoVersion in payloads
getLogoUrlsrc/lib/url/getLogoUrl.ts (client-safe, in barrel)(organizationId, version)/api/logo/5?v=… or null
Route handlersrc/app/api/logo/[organizationId]/route.tsServes the PNG with ETag + cache headers
LogoAvatarsrc/lib/ui/icons/LogoAvatar.tsxChip-slot avatar; takes organizationId + logoVersion
OrganizationAvatarsrc/lib/ui/icons/OrganizationAvatar.tsxReads organizationId/organizationLogoVersion from the org object

The version hash

logoVersionGet produces a 64-bit FNV-1a hash (16 hex chars) of the logo content. It accepts every shape a logo takes across the stack:

  • Buffer / Uint8Array (hydrated Mongoose document)
  • base64 string (lean BSON Binary through toPlain)
  • {type:'Buffer', data:[...]} (Node Buffer through toPlain)
  • {0: 255, 1: 137, ...} (index-keyed object)
  • BSON Binary-like {buffer: Uint8Array}

null/empty logos hash to null, which components translate to "render initials / render nothing". The hash is a cache-busting fingerprint, not a security feature — hence FNV instead of a cryptographic digest.

The organizationLogoVersion attribute

The version hash reaches components as organizationLogoVersion: string | null on every partner object the client receives. It is a client-side-only, derived attribute:

  • Not stored in MongoDB. The schema still stores organizationLogo: Buffer; the version is computed at serialization time by partnerLogoStrip/organizationLogoStrip, always next to the toPlain call. It is declared as an optional intersection on PartnerType (src/models/partner.ts) — not a schema field — so client code typechecks while Mongoose strict mode discards it if it is ever sent back in a save.
  • Two jobs. As an existence flag, null means "no logo": LogoAvatar renders nothing, OrganizationAvatar falls back to colored initials. As a cache-busting key, getLogoUrl(organizationId, version) embeds it as ?v= — content-derived, so the URL is stable until the logo actually changes, which is what makes the route's immutable caching safe.
  • Fingerprint, not pointer. The route handler never validates ?v=; freshness is guaranteed by the URL changing on content change, and the handler computes its own ETag from the bytes it serves as a safety net for unversioned requests.

Lifecycle

upload (organizationLogoUpdate: sharp → 256×256 PNG Buffer in DB)
→ serialization: toPlain + strip → organizationLogoVersion: "a1b2c3d4e5f60789"
→ client renders /api/logo/<id>?v=a1b2c3d4e5f60789 (browser caches forever)
→ logo changes → new hash → new URL → one new download
→ logo cleared → version: null → initials fallback / nothing rendered

Synthetic partner objects

Components that construct partner-shaped objects by hand must carry the attribute over. Example: EditActivityOrganizations builds a synthetic self-organization entry (ID 0) for the controllers/processors DnD picker and copies organizationLogoVersion from partner 0 in the org context — before the refactor this same line embedded the raw logo buffer into the picker props.

Route handler semantics

GET /api/logo/[organizationId] resolves the tenant from the Host header (same multi-tenant model as the rest of the app — see Routes). Logos are as public as the tenant's view pages, so the route requires no auth.

ConditionResponse
Non-numeric / negative ID400
Unknown tenant or partner, or partner without logo404 (Cache-Control: public, max-age=60)
?v= present200 PNG, Cache-Control: public, max-age=31536000, immutable
no ?v=200 PNG, Cache-Control: public, max-age=0, must-revalidate + ETag
If-None-Match matches304

The ?v= value itself is never validated — its presence signals "this URL changes when the content changes", which is what makes immutable safe.

Writing logos

Uploads still go through organizationLogoUpdate (sharp-sanitized to 256×256 PNG) and organizationLogoClear. Two invariants keep the client byte-free:

  1. updateOrganizationEntity (service) preserves the stored logo when organizationLogo is undefined in the incoming entity data. Clients no longer round-trip logo bytes to protect them from the full-replace update; passing null explicitly still clears.
  2. Server actions strip their responses: organizationEntityCreate/Update, organizationLogoUpdate/Clear run partnerLogoStrip over the returned entity, so PartnerForm receives the fresh organizationLogoVersion after an upload instead of an echo of the bytes.

Adding a new server→client path that includes partner data? Strip it with partnerLogoStrip/organizationLogoStrip next to the toPlain call. Server-side consumers that genuinely need bytes (PDF/HTML generators, export, Clerk logo sync) read from the service layer directly and are unaffected.

Testing

  • Tier 1: src/lib/utils/fnv1a.test.ts, src/lib/utils/logoVersion.test.ts, src/lib/url/getLogoUrl.test.ts
  • Tier 2 (route handler variant): src/app/api/logo/[organizationId]/route.test.ts — status codes, cache headers, ETag/304, serialized logo shapes

See Testing Strategy for the tier model.