Display Sender Company Logos in Your Email App (Like Superhuman)
Tutorial: how to identify company domains from email addresses and display brand logos in your email client's inbox view — with error handling, caching, and fallbacks.
Email clients that display sender logos — Superhuman, Spark, and Airmail — feel meaningfully more premium than those that do not. Logos transform a dense list of text into a scannable visual feed, letting users process their inbox 20–30% faster. This tutorial shows you exactly how to implement it.
The Architecture
At a high level, the system works like this:
1. Parse the sender email address to extract the company domain
2. Skip personal email providers (gmail, outlook, etc.)
3. Fetch the logo from LogoRouter with a local cache layer
4. Render with loading skeleton and initials fallback
Step 1: Domain Extraction
// lib/email-domain.ts
const PERSONAL_PROVIDERS = new Set([
'gmail.com', 'googlemail.com', 'yahoo.com', 'yahoo.co.uk',
'hotmail.com', 'hotmail.co.uk', 'outlook.com', 'live.com',
'icloud.com', 'me.com', 'mac.com', 'aol.com',
'protonmail.com', 'proton.me', 'pm.me',
'fastmail.com', 'fastmail.fm', 'hey.com',
'tutanota.com', 'zoho.com',
]);
export function getCompanyDomain(email: string): string | null {
const parts = email.toLowerCase().trim().split('@');
if (parts.length !== 2) return null;
const domain = parts[1];
if (!domain || PERSONAL_PROVIDERS.has(domain)) return null;
// Strip common subdomains
return domain.replace(/^(mail|smtp|email)\./, '');
}
// getCompanyDomain('alice@stripe.com') → 'stripe.com'
// getCompanyDomain('bob@gmail.com') → null
// getCompanyDomain('carl@mail.company.io') → 'company.io'Step 2: The Logo Fetcher with Deduplication
// lib/logo-fetcher.ts
type LogoStatus = 'pending' | 'resolved' | 'rejected';
interface CacheEntry {
status: LogoStatus;
url?: string;
promise?: Promise<string | null>;
}
const cache = new Map<string, CacheEntry>();
export function prefetchLogo(domain: string): void {
if (cache.has(domain)) return;
const entry: CacheEntry = { status: 'pending' };
entry.promise = fetch(
`https://img.logorouter.com/${domain}?size=80&format=webp`,
{ cache: 'force-cache' }
).then((res) => {
if (res.ok) {
const url = res.url;
cache.set(domain, { status: 'resolved', url });
return url;
}
cache.set(domain, { status: 'rejected' });
return null;
}).catch(() => {
cache.set(domain, { status: 'rejected' });
return null;
});
cache.set(domain, entry);
}
export function useLogoUrl(domain: string | null): string | null | 'loading' {
if (!domain) return null;
const entry = cache.get(domain);
if (!entry) {
prefetchLogo(domain);
return 'loading';
}
if (entry.status === 'pending') return 'loading';
if (entry.status === 'resolved') return entry.url ?? null;
return null; // rejected
}Step 3: The SenderLogo React Component
// components/sender-logo.tsx
'use client';
import { useState, useEffect } from 'react';
import { getCompanyDomain } from '@/lib/email-domain';
interface SenderLogoProps {
email: string;
senderName: string;
size?: number;
}
export function SenderLogo({ email, senderName, size = 40 }: SenderLogoProps) {
const domain = getCompanyDomain(email);
const [status, setStatus] = useState<'loading' | 'loaded' | 'error'>(
domain ? 'loading' : 'error'
);
if (!domain) {
return <PersonAvatar name={senderName} size={size} />;
}
return (
<div
className="relative flex-shrink-0 rounded-full overflow-hidden bg-muted"
style={{ width: size, height: size }}
>
{status === 'loading' && (
<div className="absolute inset-0 animate-pulse bg-muted" />
)}
{status !== 'error' && (
<img
src={`https://img.logorouter.com/${domain}?size=${size * 2}&format=webp`}
alt={`${domain} company logo`}
width={size}
height={size}
className={`absolute inset-0 object-contain p-1 transition-opacity ${
status === 'loaded' ? 'opacity-100' : 'opacity-0'
}`}
onLoad={() => setStatus('loaded')}
onError={() => setStatus('error')}
/>
)}
{status === 'error' && (
<PersonAvatar name={senderName} size={size} />
)}
</div>
);
}
function PersonAvatar({ name, size }: { name: string; size: number }) {
const initials = name.split(' ').map(n => n[0]).slice(0, 2).join('').toUpperCase();
const colors = ['bg-blue-500', 'bg-violet-500', 'bg-amber-500', 'bg-green-500', 'bg-rose-500'];
const colorIndex = name.charCodeAt(0) % colors.length;
return (
<div
className={`${colors[colorIndex]} flex items-center justify-center text-white font-semibold`}
style={{ width: size, height: size, fontSize: size * 0.35 }}
>
{initials}
</div>
);
}Step 4: Inbox Row with Prefetching
Prefetch logos as email threads load so they are ready when the user scrolls:
// components/inbox-row.tsx
import { useEffect } from 'react';
import { prefetchLogo } from '@/lib/logo-fetcher';
import { getCompanyDomain } from '@/lib/email-domain';
import { SenderLogo } from '@/components/sender-logo';
interface Email {
id: string;
from: { name: string; email: string };
subject: string;
preview: string;
date: string;
read: boolean;
}
export function InboxRow({ email }: { email: Email }) {
const domain = getCompanyDomain(email.from.email);
// Prefetch logo as soon as the row mounts
useEffect(() => {
if (domain) prefetchLogo(domain);
}, [domain]);
return (
<div className={`flex items-center gap-3 px-4 py-3 hover:bg-muted/50 cursor-pointer ${
!email.read ? 'bg-background' : 'bg-muted/20'
}`}>
<SenderLogo
email={email.from.email}
senderName={email.from.name}
size={36}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<span className={`text-sm ${!email.read ? 'font-semibold' : ''} truncate`}>
{email.from.name}
</span>
<time className="text-xs text-muted-foreground ml-2 whitespace-nowrap">
{email.date}
</time>
</div>
<p className="text-sm text-muted-foreground truncate">{email.subject}</p>
<p className="text-xs text-muted-foreground/70 truncate">{email.preview}</p>
</div>
</div>
);
}Performance Considerations
For an inbox with 100+ visible rows, pre-batch domain lookups before mounting:
// Prefetch all visible logos before rendering
emails.forEach(email => {
const domain = getCompanyDomain(email.from.email);
if (domain) prefetchLogo(domain);
});This ensures logos are in the browser cache before the component tree renders, eliminating the loading flash entirely for visible emails.
500K free requests per month — enough for any inbox
An inbox showing 50 emails uses 50 logo requests per page load. That is 10,000 page loads per month on the free tier. More than enough to get started.
Community — 500K req/mo freeCompany logos and brand data, ready in 60 seconds
500,000 requests per month, completely free. No credit card. No contracts. Upgrade to a paid plan when you are ready to scale.
- 500K requests / month free
- 30M+ company logos
- Sub-50ms global CDN
- PNG, WebP & SVG formats
- No credit card required
Topics covered
Related articles
View allBuilding Dynamic UI Themes from Company Brand Colors in React
Step-by-step tutorial: automatically adapt your app's UI to match any company's brand colors using LogoRouter's Colors API and React CSS custom properties.
Building a Production-Ready Company Logo Component in React
The definitive guide to a React logo component that handles loading states, error fallbacks, retina display, accessibility, and TypeScript — ready to drop into any codebase.
Integrating a Company Logo API with Next.js 15 (App Router)
The complete guide to adding company logos to a Next.js 15 App Router app: server components, image optimization, caching, and the Next.js Image component configuration.