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
| Piece | Location | Role |
|---|---|---|
fnv1a, fnv1a64 | src/lib/utils/fnv1a.ts | Dependency-free non-cryptographic hash over a byte stream |
logoVersionGet | src/lib/utils/logoVersion.ts | 16-hex-char content hash of a logo in any serialized shape |
partnerLogoStrip / organizationLogoStrip | src/lib/utils/logoVersion.ts | Replace organizationLogo with organizationLogoVersion in payloads |
getLogoUrl | src/lib/url/getLogoUrl.ts (client-safe, in barrel) | (organizationId, version) → /api/logo/5?v=… or null |
| Route handler | src/app/api/logo/[organizationId]/route.ts | Serves the PNG with ETag + cache headers |
LogoAvatar | src/lib/ui/icons/LogoAvatar.tsx | Chip-slot avatar; takes organizationId + logoVersion |
OrganizationAvatar | src/lib/ui/icons/OrganizationAvatar.tsx | Reads 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
BinarythroughtoPlain) {type:'Buffer', data:[...]}(NodeBufferthroughtoPlain){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 bypartnerLogoStrip/organizationLogoStrip, always next to thetoPlaincall. It is declared as an optional intersection onPartnerType(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,
nullmeans "no logo":LogoAvatarrenders nothing,OrganizationAvatarfalls 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'simmutablecaching 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 ownETagfrom 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.
| Condition | Response |
|---|---|
| Non-numeric / negative ID | 400 |
| Unknown tenant or partner, or partner without logo | 404 (Cache-Control: public, max-age=60) |
?v= present | 200 PNG, Cache-Control: public, max-age=31536000, immutable |
no ?v= | 200 PNG, Cache-Control: public, max-age=0, must-revalidate + ETag |
If-None-Match matches | 304 |
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:
updateOrganizationEntity(service) preserves the stored logo whenorganizationLogoisundefinedin the incoming entity data. Clients no longer round-trip logo bytes to protect them from the full-replace update; passingnullexplicitly still clears.- Server actions strip their responses:
organizationEntityCreate/Update,organizationLogoUpdate/ClearrunpartnerLogoStripover the returned entity, soPartnerFormreceives the freshorganizationLogoVersionafter 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.