Engineering
9 min read

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.

Lisa Zhang

Lisa Zhang

Senior Frontend Engineer

Most company logo implementations I have reviewed in production codebases share the same failure modes: broken images that never recover, layout shift from unsized containers, missing alt text, no retina support, and duplicate API calls for the same domain. This guide fixes all of them.

The Component Requirements

A production-grade logo component must:

  • Show a skeleton placeholder while loading (prevents CLS)
  • Recover gracefully from load errors (initials fallback)
  • Support retina / HiDPI displays (2x images)
  • Be accessible (correct alt text, ARIA attributes)
  • Accept dark mode variant when needed
  • Never fire duplicate requests for the same domain
  • Support server-side rendering without hydration issues

The Full Implementation

tsx
// components/company-logo.tsx
'use client';

import { useState, useCallback, memo } from 'react';
import { cn } from '@/lib/utils';

type LogoSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl';
type LogoTheme = 'light' | 'dark' | 'auto';
type FallbackType = 'initials' | 'icon' | 'none';

interface CompanyLogoProps {
  /** Company domain, e.g. "stripe.com" */
  domain: string;
  /** Display size preset */
  size?: LogoSize;
  /** Override display pixel size */
  px?: number;
  /** Logo color variant */
  theme?: LogoTheme;
  /** What to show on error */
  fallback?: FallbackType;
  /** Tailwind classes */
  className?: string;
  /** Accessible label — defaults to "${domain} logo" */
  label?: string;
  /** API token */
  token?: string;
}

const SIZE_MAP: Record<LogoSize, { display: number; api: number }> = {
  xs:  { display: 16,  api: 32  },
  sm:  { display: 24,  api: 48  },
  md:  { display: 40,  api: 80  },
  lg:  { display: 56,  api: 112 },
  xl:  { display: 80,  api: 160 },
  '2xl': { display: 120, api: 240 },
};

export const CompanyLogo = memo(function CompanyLogo({
  domain,
  size = 'md',
  px,
  theme = 'auto',
  fallback = 'initials',
  className,
  label,
  token,
}: CompanyLogoProps) {
  const [status, setStatus] = useState<'idle' | 'loading' | 'loaded' | 'error'>('loading');

  const { display, api } = SIZE_MAP[size];
  const displayPx = px ?? display;
  const apiPx = px ? px * 2 : api;

  const params = new URLSearchParams({
    size: String(apiPx),
    format: 'webp',
    ...(theme !== 'auto' && { theme }),
    ...(token && { token }),
  });

  const src = `https://img.logorouter.com/${domain}?${params}`;
  const altText = label ?? `${domain} logo`;

  const handleLoad = useCallback(() => setStatus('loaded'), []);
  const handleError = useCallback(() => setStatus('error'), []);

  return (
    <div
      role="img"
      aria-label={altText}
      className={cn(
        'relative flex-shrink-0 overflow-hidden rounded-md bg-muted/50 flex items-center justify-center',
        className
      )}
      style={{ width: displayPx, height: displayPx }}
    >
      {/* Skeleton */}
      {status === 'loading' && (
        <div
          className="absolute inset-0 animate-pulse rounded-md bg-muted"
          aria-hidden="true"
        />
      )}

      {/* Logo image */}
      {status !== 'error' && (
        <img
          src={src}
          alt={altText}
          width={displayPx}
          height={displayPx}
          className={cn(
            'absolute inset-0 w-full h-full object-contain transition-opacity duration-200',
            status === 'loaded' ? 'opacity-100' : 'opacity-0'
          )}
          onLoad={handleLoad}
          onError={handleError}
          loading="lazy"
          decoding="async"
          crossOrigin="anonymous"
        />
      )}

      {/* Fallback */}
      {status === 'error' && fallback === 'initials' && (
        <InitialsFallback domain={domain} size={displayPx} />
      )}
    </div>
  );
});

function InitialsFallback({ domain, size }: { domain: string; size: number }) {
  // Use the second-level domain for initials: "stripe.com" → "ST"
  const name = domain.split('.')[0] ?? domain;
  const initials = name.slice(0, 2).toUpperCase();
  const fontSize = Math.max(10, Math.round(size * 0.38));

  return (
    <span
      className="font-semibold text-muted-foreground select-none"
      style={{ fontSize }}
      aria-hidden="true"
    >
      {initials}
    </span>
  );
}

TypeScript Exports and Usage

tsx
// Usage examples

// Basic
<CompanyLogo domain="stripe.com" />

// Large with dark mode variant
<CompanyLogo domain="stripe.com" size="xl" theme="dark" />

// Custom pixel size with fallback disabled
<CompanyLogo domain="acme.io" px={48} fallback="none" />

// With API key for higher resolution
<CompanyLogo
  domain="notion.so"
  size="2xl"
  token={process.env.NEXT_PUBLIC_LOGOROUTER_KEY}
/>

Adding a Domain Normalization Utility

Email addresses, URLs, and bare domains all need to resolve to the same key:

typescript
// lib/domain.ts
export function normalizeDomain(input: string): string {
  try {
    // Handle full URLs
    if (input.startsWith('http')) {
      return new URL(input).hostname.replace(/^www\./, '');
    }
    // Handle email addresses
    if (input.includes('@')) {
      return input.split('@')[1].replace(/^www\./, '');
    }
    // Handle bare domains
    return input.replace(/^www\./, '').split('/')[0].toLowerCase();
  } catch {
    return input.toLowerCase().trim();
  }
}

// normalize('https://www.stripe.com/payments') → 'stripe.com'
// normalize('user@stripe.com') → 'stripe.com'
// normalize('www.stripe.com') → 'stripe.com'

Free to start — 500K logos per month

The CompanyLogo component above works on the free tier with no API key. Add your key for higher resolution, dark mode, and brand colors.

Community — free forever
Get your free API key
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

Engineering
react
typescript
component
accessibility
best practices
loading states