Tutorials
11 min read

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.

David Park

David Park

Frontend Engineer

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

typescript
// 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

typescript
// 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

tsx
// 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:

tsx
// 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:

typescript
// 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 free
Start free — no credit card
Start building today

Company 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

Tutorials
email
tutorial
react
inbox
sender logos
ux