One of the hard questions in a multi-tenant ERP system: how do you let every tenant see its own brand, without maintaining 200 separate storefront themes? The Netorigo Admin brand module is a compromise: one tenant = one brand kit, and every frontend surface derives its look from there.
What's in the brand kit
The tenant_brand table contains:
- Logo - SVG or PNG, max 2 MB, S3-backed with signed URLs, required
lightand optionaldarkvariant. - Color tokens - 8 of them: primary, primaryHover, accent, surface, surfaceMuted, text, textMuted, danger. All hex, validated (the UI refuses to save a
primary/surfacepair with contrast below 4.5, because that would fail WCAG AA). - OG image - Open Graph sharing, 1200x630 px, required.
- Favicon - .ico or .svg, multi-size (16, 32, 180, 512).
- Email header banner - 600 px wide PNG, for transactional emails (order confirmation, password reset).
- Brand font - one of 14 curated Google Fonts, or a custom WOFF2 (max 200 KB, required subset: latin-ext).
How it reaches the frontend
/api/v1/tenant/:slug/brand is a public, cacheable endpoint (Vercel edge cache, 5-minute TTL). Every frontend app (admin, storefront, finance, logistics) calls it in the layout, then injects the color tokens into a <style> block as CSS variables:
:root {
--brand-primary: #1e40af;
--brand-primary-hover: #1d4ed8;
/* ... */
}
On the Tailwind v4 side we use these trivially: bg-[var(--brand-primary)], or via our own bg-brand-primary utility registered in the @theme block.
Live preview
The brand editor is a two-column layout: form on the left, an <iframe> on the right loading the tenant landing page with ?preview=<draft-id>. Every change (onChange, 300 ms debounce) sends a postMessage to the iframe, which updates CSS variables on-the-fly. So the operator sees the logo swap and color change in real time without having to save.
Saving creates a tenant_brand_draft row that the Publish button promotes to tenant_brand. Drafts are auto-deleted after 30 days.
The bug that bit us: CDN cache-busting on logo replace
In March 2026 a customer reported that a replaced logo was still showing the old one on the public storefront. Investigation: the CDN had cached logo.png for 24 hours (Cache-Control: max-age=86400). The fix is two-part:
- Version query string - every brand-asset URL gets
?v=<sha256_8>where the hash is content-derived. The CDN sees a new file when content changes. - Active CDN purge - the Publish button fires a webhook to the Cloudflare API that purges the
logo.*pattern from the tenant cache. Takes about 30 seconds globally.
Defense in depth: the versioned URL works immediately, the CDN purge ensures the 24-hour cache doesn't bite if some consumer somehow misses the version query.
The decision: one tenant = one brand
The roadmap had a multi-storefront brand-override (one tenant managing 3-4 separate brands, e.g. a parent company plus two subsidiaries). We deferred it: the complexity (per-storefront layout injection, per-storefront cache key, per-storefront preview) would have doubled the module's code, and of 47 customers surveyed in Q1 2026 only 4 had a real need. We solved those 4 with manual tenant separation: each brand gets its own tenant record, its own users, a shared product catalog via a tenant-share module.
Future (likely 2026 H2): per-channel branding (web vs. email vs. PDF with different colors), still within the one tenant = one brand constraint.