Building a Link Preview Component in Next.js 15
Tutorial17 min read

Building a Link Preview Component in Next.js 15

A comprehensive guide to creating a professional link preview component using Next.js 15 Server Components, Server Actions, and the Katsau API. Includes streaming, caching, error handling, and advanced patterns.

Katsau

Katsau Team

December 27, 2025

Share:

Link previews have become a standard feature in modern web applications. Slack, Discord, Twitter, LinkedIn — they all transform plain URLs into rich, engaging cards that show the page's title, description, and image. In this comprehensive tutorial, we'll build a professional link preview system using Next.js 15's latest features.

By the end of this guide, you'll have a production-ready link preview component that rivals what you see in enterprise applications.

What We're Building

Our link preview system will include:

Feature Description
Server Components Metadata fetched server-side, zero client JS overhead
Streaming with Suspense Progressive loading for optimal UX
Intelligent Caching Next.js fetch cache with configurable TTL
Error Boundaries Graceful degradation when fetching fails
TypeScript Full type safety with comprehensive interfaces
Responsive Design Looks great on mobile, tablet, and desktop
Accessibility WCAG 2.1 compliant implementation
Dark Mode Automatic theme detection and styling

Understanding the Challenge

Why Link Previews Are Hard

Building link previews seems simple until you try it. Here are the challenges you'll face:

1. CORS Restrictions

Browsers block cross-origin requests for security. You can't just fetch a URL from the client:

// ❌ This won't work in the browser
fetch('https://example.com')
  .then(res => res.text())
  .then(html => parseMetadata(html))
// Error: CORS policy blocked

2. Metadata Format Variations

Different sites use different metadata formats:

Format Example Common On
Open Graph <meta property="og:title"> Facebook, LinkedIn
Twitter Cards <meta name="twitter:title"> Twitter/X
Standard Meta <meta name="description"> Everywhere
JSON-LD <script type="application/ld+json"> SEO-focused sites
Dublin Core <meta name="DC.title"> Academic sites

3. Dynamic Content

Many modern sites render content with JavaScript, meaning the initial HTML doesn't contain the metadata:

<!-- What you fetch -->
<html>
  <head></head>
  <body><div id="root"></div></body>
</html>

<!-- What the browser sees after JS execution -->
<html>
  <head>
    <meta property="og:title" content="Actual Title">
  </head>
  ...
</html>

4. Edge Cases

  • Redirects (HTTP → HTTPS, www → non-www)
  • Relative URLs in metadata
  • Character encoding issues
  • Rate limiting
  • Timeout handling
  • Invalid URLs

The Solution: Server Components + Metadata API

Next.js 15 Server Components solve the CORS problem — we fetch metadata on the server. A metadata API like Katsau handles all the parsing complexity.

Project Setup

Create Next.js Project

npx create-next-app@latest link-preview-demo \
  --typescript \
  --tailwind \
  --eslint \
  --app \
  --src-dir \
  --import-alias "@/*"

cd link-preview-demo

Install Dependencies

npm install lucide-react clsx tailwind-merge
npm install -D @types/node

Configure Environment

Create .env.local:

KATSAU_API_KEY=your_api_key_here

Project Structure

src/
├── actions/
│   ├── metadata.ts          # Server actions
│   └── batch-metadata.ts    # Batch processing
├── components/
│   ├── LinkPreview/
│   │   ├── index.tsx        # Main component
│   │   ├── Skeleton.tsx     # Loading state
│   │   ├── Fallback.tsx     # Error state
│   │   └── types.ts         # Type definitions
│   └── ui/
│       └── Card.tsx         # Reusable card component
├── lib/
│   └── utils.ts             # Utility functions
└── app/
    └── page.tsx             # Demo page

Type Definitions

Let's start with comprehensive type definitions:

// src/components/LinkPreview/types.ts

/**
 * Open Graph metadata structure
 */
export interface OpenGraphData {
  title?: string;
  description?: string;
  image?: string;
  imageAlt?: string;
  imageWidth?: number;
  imageHeight?: number;
  siteName?: string;
  type?: 'website' | 'article' | 'video' | 'music' | 'book' | 'profile';
  url?: string;
  locale?: string;
}

/**
 * Twitter Card metadata structure
 */
export interface TwitterCardData {
  card?: 'summary' | 'summary_large_image' | 'app' | 'player';
  site?: string;
  creator?: string;
  title?: string;
  description?: string;
  image?: string;
  imageAlt?: string;
}

/**
 * Complete metadata response from API
 */
export interface MetadataResponse {
  url: string;
  finalUrl: string;
  title: string;
  description: string;
  favicon: string;
  image: string;
  openGraph: OpenGraphData;
  twitter: TwitterCardData;
  theme: {
    color: string;
    colorScheme: 'light' | 'dark';
  };
  links: {
    canonical?: string;
  };
}

/**
 * Simplified metadata for display
 */
export interface LinkMetadata {
  title: string;
  description: string | null;
  image: string | null;
  imageAlt: string | null;
  favicon: string | null;
  siteName: string | null;
  url: string;
  finalUrl: string;
  type: OpenGraphData['type'];
  themeColor: string | null;
}

/**
 * Props for LinkPreview component
 */
export interface LinkPreviewProps {
  /** The URL to generate a preview for */
  url: string;
  /** Additional CSS classes */
  className?: string;
  /** Size variant */
  size?: 'sm' | 'md' | 'lg';
  /** Whether to show the image */
  showImage?: boolean;
  /** Maximum lines for title */
  titleLines?: 1 | 2 | 3;
  /** Maximum lines for description */
  descriptionLines?: 1 | 2 | 3;
}

/**
 * API Error response
 */
export interface ApiError {
  code: string;
  message: string;
  details?: Record<string, unknown>;
}

Utility Functions

Create helper utilities:

// src/lib/utils.ts

import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';

/**
 * Merge Tailwind CSS classes with conflict resolution
 */
export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

/**
 * Extract hostname from URL
 */
export function getHostname(url: string): string {
  try {
    return new URL(url).hostname.replace(/^www\./, '');
  } catch {
    return url;
  }
}

/**
 * Truncate text to a maximum length with ellipsis
 */
export function truncate(text: string, maxLength: number): string {
  if (text.length <= maxLength) return text;
  return text.slice(0, maxLength - 3).trim() + '...';
}

/**
 * Check if a string is a valid URL
 */
export function isValidUrl(string: string): boolean {
  try {
    new URL(string);
    return true;
  } catch {
    return false;
  }
}

/**
 * Convert relative URL to absolute
 */
export function toAbsoluteUrl(url: string, base: string): string {
  try {
    return new URL(url, base).href;
  } catch {
    return url;
  }
}

/**
 * Get contrast color (black or white) for a background color
 */
export function getContrastColor(hexColor: string): 'black' | 'white' {
  const hex = hexColor.replace('#', '');
  const r = parseInt(hex.substring(0, 2), 16);
  const g = parseInt(hex.substring(2, 4), 16);
  const b = parseInt(hex.substring(4, 6), 16);
  const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
  return luminance > 0.5 ? 'black' : 'white';
}

Server Action for Metadata Fetching

Create the server action that fetches metadata:

// src/actions/metadata.ts
'use server';

import { LinkMetadata, MetadataResponse, ApiError } from '@/components/LinkPreview/types';
import { isValidUrl } from '@/lib/utils';

const API_BASE = 'https://api.katsau.com/v1';
const API_KEY = process.env.KATSAU_API_KEY;

if (!API_KEY) {
  console.warn('KATSAU_API_KEY is not set. Link previews will not work.');
}

/**
 * Fetch options with caching configuration
 */
interface FetchMetadataOptions {
  /** Cache revalidation time in seconds */
  revalidate?: number;
  /** Custom timeout in milliseconds */
  timeout?: number;
  /** Whether to follow redirects */
  followRedirects?: boolean;
}

/**
 * Fetch metadata for a single URL
 */
export async function fetchMetadata(
  url: string,
  options: FetchMetadataOptions = {}
): Promise<LinkMetadata | null> {
  const {
    revalidate = 3600, // 1 hour default
    timeout = 10000,   // 10 seconds default
  } = options;

  // Validate URL
  if (!isValidUrl(url)) {
    console.error(`Invalid URL: ${url}`);
    return null;
  }

  // Check API key
  if (!API_KEY) {
    console.error('API key not configured');
    return null;
  }

  try {
    const controller = new AbortController();
    const timeoutId = setTimeout(() => controller.abort(), timeout);

    const response = await fetch(
      `${API_BASE}/extract?url=${encodeURIComponent(url)}`,
      {
        method: 'GET',
        headers: {
          'Authorization': `Bearer ${API_KEY}`,
          'Content-Type': 'application/json',
          'Accept': 'application/json',
        },
        signal: controller.signal,
        next: { revalidate },
      }
    );

    clearTimeout(timeoutId);

    if (!response.ok) {
      const errorData = await response.json().catch(() => ({})) as ApiError;
      console.error(`API error for ${url}:`, errorData);
      return null;
    }

    const { data } = await response.json() as { data: MetadataResponse };

    // Transform to simplified format
    return transformMetadata(data, url);
  } catch (error) {
    if (error instanceof Error) {
      if (error.name === 'AbortError') {
        console.error(`Timeout fetching metadata for ${url}`);
      } else {
        console.error(`Error fetching metadata for ${url}:`, error.message);
      }
    }
    return null;
  }
}

/**
 * Transform API response to simplified metadata
 */
function transformMetadata(data: MetadataResponse, originalUrl: string): LinkMetadata {
  const og = data.openGraph || {};
  const twitter = data.twitter || {};

  return {
    // Prefer OG title, fall back to page title
    title: og.title || twitter.title || data.title || 'Untitled',
    
    // Prefer OG description, fall back to meta description
    description: og.description || twitter.description || data.description || null,
    
    // Prefer OG image, fall back to Twitter image
    image: og.image || twitter.image || data.image || null,
    
    // Image alt text
    imageAlt: og.imageAlt || twitter.imageAlt || null,
    
    // Favicon
    favicon: data.favicon || null,
    
    // Site name
    siteName: og.siteName || null,
    
    // URLs
    url: originalUrl,
    finalUrl: data.finalUrl || data.url || originalUrl,
    
    // Content type
    type: og.type || 'website',
    
    // Theme color
    themeColor: data.theme?.color || null,
  };
}

/**
 * Prefetch metadata (for hover prefetching)
 */
export async function prefetchMetadata(url: string): Promise<void> {
  // Just fetch without returning - this populates the cache
  await fetchMetadata(url, { revalidate: 3600 });
}

Batch Metadata Fetching

For efficiency when fetching multiple URLs:

// src/actions/batch-metadata.ts
'use server';

import { LinkMetadata, MetadataResponse } from '@/components/LinkPreview/types';

const API_BASE = 'https://api.katsau.com/v1';
const API_KEY = process.env.KATSAU_API_KEY;

interface BatchResult {
  url: string;
  success: boolean;
  data?: LinkMetadata;
  error?: string;
}

/**
 * Fetch metadata for multiple URLs in a single request
 * More efficient than multiple individual requests
 */
export async function fetchBatchMetadata(
  urls: string[],
  options: { revalidate?: number } = {}
): Promise<BatchResult[]> {
  const { revalidate = 3600 } = options;

  if (!API_KEY) {
    console.error('API key not configured');
    return urls.map(url => ({
      url,
      success: false,
      error: 'API key not configured',
    }));
  }

  // Limit to 100 URLs per batch (API limit)
  const limitedUrls = urls.slice(0, 100);

  try {
    const response = await fetch(`${API_BASE}/batch/extract`, {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${API_KEY}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ urls: limitedUrls }),
      next: { revalidate },
    });

    if (!response.ok) {
      throw new Error(`API returned ${response.status}`);
    }

    const { data } = await response.json() as {
      data: {
        results: Array<{
          url: string;
          success: boolean;
          data?: MetadataResponse;
          error?: string;
        }>;
      };
    };

    return data.results.map(result => ({
      url: result.url,
      success: result.success,
      data: result.data ? transformToLinkMetadata(result.data, result.url) : undefined,
      error: result.error,
    }));
  } catch (error) {
    console.error('Batch fetch error:', error);
    return limitedUrls.map(url => ({
      url,
      success: false,
      error: 'Batch request failed',
    }));
  }
}

function transformToLinkMetadata(data: MetadataResponse, originalUrl: string): LinkMetadata {
  const og = data.openGraph || {};
  const twitter = data.twitter || {};

  return {
    title: og.title || twitter.title || data.title || 'Untitled',
    description: og.description || twitter.description || data.description || null,
    image: og.image || twitter.image || data.image || null,
    imageAlt: og.imageAlt || twitter.imageAlt || null,
    favicon: data.favicon || null,
    siteName: og.siteName || null,
    url: originalUrl,
    finalUrl: data.finalUrl || data.url || originalUrl,
    type: og.type || 'website',
    themeColor: data.theme?.color || null,
  };
}

Link Preview Component

Now the main component with multiple size variants:

// src/components/LinkPreview/index.tsx

import { fetchMetadata } from '@/actions/metadata';
import { cn, getHostname } from '@/lib/utils';
import { LinkPreviewProps, LinkMetadata } from './types';
import { LinkPreviewFallback } from './Fallback';
import { ExternalLink, Globe, Image as ImageIcon } from 'lucide-react';

/**
 * Server Component that displays a rich link preview
 */
export async function LinkPreview({
  url,
  className,
  size = 'md',
  showImage = true,
  titleLines = 2,
  descriptionLines = 2,
}: LinkPreviewProps) {
  const metadata = await fetchMetadata(url);

  if (!metadata) {
    return <LinkPreviewFallback url={url} size={size} className={className} />;
  }

  return (
    <LinkPreviewCard
      metadata={metadata}
      className={className}
      size={size}
      showImage={showImage}
      titleLines={titleLines}
      descriptionLines={descriptionLines}
    />
  );
}

/**
 * The actual card component (can be used separately with pre-fetched data)
 */
interface LinkPreviewCardProps {
  metadata: LinkMetadata;
  className?: string;
  size?: 'sm' | 'md' | 'lg';
  showImage?: boolean;
  titleLines?: 1 | 2 | 3;
  descriptionLines?: 1 | 2 | 3;
}

export function LinkPreviewCard({
  metadata,
  className,
  size = 'md',
  showImage = true,
  titleLines = 2,
  descriptionLines = 2,
}: LinkPreviewCardProps) {
  const hasImage = showImage && metadata.image;
  const hostname = getHostname(metadata.finalUrl);

  // Size-specific styles
  const sizeStyles = {
    sm: {
      container: 'max-w-sm',
      image: 'aspect-[2/1]',
      padding: 'p-3',
      title: 'text-sm',
      description: 'text-xs',
      meta: 'text-xs',
      favicon: 'w-3 h-3',
    },
    md: {
      container: 'max-w-lg',
      image: 'aspect-[1.91/1]',
      padding: 'p-4',
      title: 'text-base',
      description: 'text-sm',
      meta: 'text-xs',
      favicon: 'w-4 h-4',
    },
    lg: {
      container: 'max-w-2xl',
      image: 'aspect-[1.91/1]',
      padding: 'p-6',
      title: 'text-lg',
      description: 'text-base',
      meta: 'text-sm',
      favicon: 'w-5 h-5',
    },
  };

  const styles = sizeStyles[size];
  const lineClamp = {
    1: 'line-clamp-1',
    2: 'line-clamp-2',
    3: 'line-clamp-3',
  };

  return (
    <a
      href={metadata.url}
      target="_blank"
      rel="noopener noreferrer"
      className={cn(
        'group block w-full rounded-xl overflow-hidden',
        'border border-zinc-200 dark:border-zinc-800',
        'bg-white dark:bg-zinc-900',
        'hover:border-zinc-300 dark:hover:border-zinc-700',
        'hover:shadow-lg hover:shadow-zinc-200/50 dark:hover:shadow-zinc-900/50',
        'transition-all duration-300 ease-out',
        'focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2',
        styles.container,
        className
      )}
      aria-label={`Link preview for ${metadata.title}`}
    >
      {/* Image Section */}
      {hasImage && (
        <div className={cn('relative overflow-hidden bg-zinc-100 dark:bg-zinc-800', styles.image)}>
          <img
            src={metadata.image!}
            alt={metadata.imageAlt || metadata.title}
            className={cn(
              'w-full h-full object-cover',
              'group-hover:scale-105 transition-transform duration-500 ease-out'
            )}
            loading="lazy"
          />
          {/* Gradient overlay */}
          <div className="absolute inset-0 bg-gradient-to-t from-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
          
          {/* Type badge */}
          {metadata.type !== 'website' && (
            <div className="absolute top-2 right-2 px-2 py-1 rounded-md bg-black/50 backdrop-blur-sm text-white text-xs font-medium capitalize">
              {metadata.type}
            </div>
          )}
        </div>
      )}

      {/* Content Section */}
      <div className={styles.padding}>
        {/* Site info row */}
        <div className="flex items-center gap-2 mb-2">
          {metadata.favicon ? (
            <img
              src={metadata.favicon}
              alt=""
              className={cn(styles.favicon, 'rounded-sm flex-shrink-0')}
              loading="lazy"
            />
          ) : (
            <Globe className={cn(styles.favicon, 'text-zinc-400 flex-shrink-0')} />
          )}
          <span className={cn(
            styles.meta,
            'text-zinc-500 dark:text-zinc-400 truncate uppercase tracking-wider font-medium'
          )}>
            {metadata.siteName || hostname}
          </span>
        </div>

        {/* Title */}
        <h3 className={cn(
          styles.title,
          lineClamp[titleLines],
          'font-semibold text-zinc-900 dark:text-zinc-100',
          'group-hover:text-blue-600 dark:group-hover:text-blue-400',
          'transition-colors duration-200'
        )}>
          {metadata.title}
        </h3>

        {/* Description */}
        {metadata.description && (
          <p className={cn(
            styles.description,
            lineClamp[descriptionLines],
            'mt-1 text-zinc-600 dark:text-zinc-400'
          )}>
            {metadata.description}
          </p>
        )}

        {/* URL row */}
        <div className={cn(
          'flex items-center gap-1 mt-3',
          styles.meta,
          'text-zinc-400 dark:text-zinc-500'
        )}>
          <ExternalLink className="w-3 h-3 flex-shrink-0" />
          <span className="truncate">{hostname}</span>
        </div>
      </div>

      {/* Theme color accent (optional) */}
      {metadata.themeColor && (
        <div
          className="h-1 w-full"
          style={{ backgroundColor: metadata.themeColor }}
          aria-hidden="true"
        />
      )}
    </a>
  );
}

// Re-export types and sub-components
export { LinkPreviewSkeleton } from './Skeleton';
export { LinkPreviewFallback } from './Fallback';
export type { LinkPreviewProps, LinkMetadata } from './types';

Loading Skeleton

A polished skeleton for loading states:

// src/components/LinkPreview/Skeleton.tsx

import { cn } from '@/lib/utils';

interface LinkPreviewSkeletonProps {
  size?: 'sm' | 'md' | 'lg';
  showImage?: boolean;
  className?: string;
}

export function LinkPreviewSkeleton({
  size = 'md',
  showImage = true,
  className,
}: LinkPreviewSkeletonProps) {
  const sizeStyles = {
    sm: {
      container: 'max-w-sm',
      image: 'aspect-[2/1]',
      padding: 'p-3',
      title: 'h-4',
      description: 'h-3',
      favicon: 'w-3 h-3',
    },
    md: {
      container: 'max-w-lg',
      image: 'aspect-[1.91/1]',
      padding: 'p-4',
      title: 'h-5',
      description: 'h-4',
      favicon: 'w-4 h-4',
    },
    lg: {
      container: 'max-w-2xl',
      image: 'aspect-[1.91/1]',
      padding: 'p-6',
      title: 'h-6',
      description: 'h-4',
      favicon: 'w-5 h-5',
    },
  };

  const styles = sizeStyles[size];

  return (
    <div
      className={cn(
        'rounded-xl overflow-hidden',
        'border border-zinc-200 dark:border-zinc-800',
        'bg-white dark:bg-zinc-900',
        styles.container,
        className
      )}
      role="status"
      aria-label="Loading link preview..."
    >
      {/* Image placeholder */}
      {showImage && (
        <div className={cn(
          styles.image,
          'bg-zinc-100 dark:bg-zinc-800',
          'animate-pulse'
        )}>
          <div className="w-full h-full flex items-center justify-center">
            <svg
              className="w-10 h-10 text-zinc-200 dark:text-zinc-700"
              aria-hidden="true"
              fill="currentColor"
              viewBox="0 0 20 18"
            >
              <path d="M18 0H2a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2Zm-5.5 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3Zm4.376 10.481A1 1 0 0 1 16 15H4a1 1 0 0 1-.895-1.447l3.5-7A1 1 0 0 1 7.468 6a.965.965 0 0 1 .9.5l2.775 4.757 1.546-1.887a1 1 0 0 1 1.618.1l2.541 4a1 1 0 0 1 .028 1.011Z" />
            </svg>
          </div>
        </div>
      )}

      {/* Content placeholder */}
      <div className={cn(styles.padding, 'space-y-3')}>
        {/* Site info */}
        <div className="flex items-center gap-2">
          <div className={cn(
            styles.favicon,
            'rounded bg-zinc-200 dark:bg-zinc-700 animate-pulse'
          )} />
          <div className="h-3 w-20 rounded bg-zinc-200 dark:bg-zinc-700 animate-pulse" />
        </div>

        {/* Title */}
        <div className="space-y-2">
          <div className={cn(
            'w-full rounded bg-zinc-200 dark:bg-zinc-700 animate-pulse',
            styles.title
          )} />
          <div className={cn(
            'w-3/4 rounded bg-zinc-200 dark:bg-zinc-700 animate-pulse',
            styles.title
          )} />
        </div>

        {/* Description */}
        <div className="space-y-2">
          <div className={cn(
            'w-full rounded bg-zinc-200 dark:bg-zinc-700 animate-pulse',
            styles.description
          )} />
          <div className={cn(
            'w-5/6 rounded bg-zinc-200 dark:bg-zinc-700 animate-pulse',
            styles.description
          )} />
        </div>

        {/* URL */}
        <div className="h-3 w-32 rounded bg-zinc-200 dark:bg-zinc-700 animate-pulse" />
      </div>
    </div>
  );
}

Error Fallback

When metadata can't be fetched:

// src/components/LinkPreview/Fallback.tsx

import { cn, getHostname } from '@/lib/utils';
import { ExternalLink, AlertCircle } from 'lucide-react';

interface LinkPreviewFallbackProps {
  url: string;
  size?: 'sm' | 'md' | 'lg';
  className?: string;
  error?: string;
}

export function LinkPreviewFallback({
  url,
  size = 'md',
  className,
  error,
}: LinkPreviewFallbackProps) {
  const hostname = getHostname(url);

  const sizeStyles = {
    sm: { padding: 'p-3', icon: 'w-8 h-8', text: 'text-sm' },
    md: { padding: 'p-4', icon: 'w-10 h-10', text: 'text-base' },
    lg: { padding: 'p-6', icon: 'w-12 h-12', text: 'text-lg' },
  };

  const styles = sizeStyles[size];

  return (
    <a
      href={url}
      target="_blank"
      rel="noopener noreferrer"
      className={cn(
        'group flex items-center gap-4 rounded-xl',
        'border border-zinc-200 dark:border-zinc-800',
        'bg-white dark:bg-zinc-900',
        'hover:border-zinc-300 dark:hover:border-zinc-700',
        'hover:bg-zinc-50 dark:hover:bg-zinc-800/50',
        'transition-all duration-200',
        'focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2',
        styles.padding,
        className
      )}
    >
      {/* Icon */}
      <div className={cn(
        'rounded-xl flex items-center justify-center flex-shrink-0',
        'bg-zinc-100 dark:bg-zinc-800',
        'text-zinc-400 dark:text-zinc-500',
        'group-hover:bg-blue-50 dark:group-hover:bg-blue-500/10',
        'group-hover:text-blue-500',
        'transition-colors duration-200',
        styles.icon
      )}>
        {error ? (
          <AlertCircle className="w-1/2 h-1/2" />
        ) : (
          <ExternalLink className="w-1/2 h-1/2" />
        )}
      </div>

      {/* Text */}
      <div className="flex-1 min-w-0">
        <p className={cn(
          'font-medium text-zinc-900 dark:text-zinc-100 truncate',
          'group-hover:text-blue-600 dark:group-hover:text-blue-400',
          'transition-colors duration-200',
          styles.text
        )}>
          {hostname}
        </p>
        <p className="text-sm text-zinc-500 dark:text-zinc-400 truncate">
          {url}
        </p>
        {error && (
          <p className="text-xs text-red-500 dark:text-red-400 mt-1">
            {error}
          </p>
        )}
      </div>

      {/* Arrow */}
      <ExternalLink className={cn(
        'w-4 h-4 flex-shrink-0',
        'text-zinc-400 dark:text-zinc-600',
        'group-hover:text-blue-500',
        'transition-all duration-200',
        'group-hover:translate-x-0.5 group-hover:-translate-y-0.5'
      )} />
    </a>
  );
}

Using the Component

Basic Usage with Suspense

// src/app/page.tsx

import { Suspense } from 'react';
import { LinkPreview, LinkPreviewSkeleton } from '@/components/LinkPreview';

export default function Page() {
  const urls = [
    'https://github.com',
    'https://vercel.com',
    'https://nextjs.org',
    'https://tailwindcss.com',
    'https://react.dev',
    'https://www.typescriptlang.org',
  ];

  return (
    <main className="min-h-screen bg-zinc-50 dark:bg-zinc-950 py-12 px-4">
      <div className="max-w-5xl mx-auto">
        <h1 className="text-3xl font-bold text-center mb-8 text-zinc-900 dark:text-zinc-100">
          Link Preview Demo
        </h1>

        {/* Grid of previews */}
        <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
          {urls.map((url) => (
            <Suspense key={url} fallback={<LinkPreviewSkeleton />}>
              <LinkPreview url={url} />
            </Suspense>
          ))}
        </div>

        {/* Size variants */}
        <section className="mt-16">
          <h2 className="text-xl font-semibold mb-6 text-zinc-900 dark:text-zinc-100">
            Size Variants
          </h2>
          <div className="space-y-6">
            <Suspense fallback={<LinkPreviewSkeleton size="sm" />}>
              <LinkPreview url="https://github.com" size="sm" />
            </Suspense>
            <Suspense fallback={<LinkPreviewSkeleton size="md" />}>
              <LinkPreview url="https://vercel.com" size="md" />
            </Suspense>
            <Suspense fallback={<LinkPreviewSkeleton size="lg" />}>
              <LinkPreview url="https://nextjs.org" size="lg" />
            </Suspense>
          </div>
        </section>
      </div>
    </main>
  );
}

Batch Fetching for Lists

// src/app/links/page.tsx

import { fetchBatchMetadata } from '@/actions/batch-metadata';
import { LinkPreviewCard, LinkPreviewFallback } from '@/components/LinkPreview';

export default async function LinksPage() {
  const urls = [
    'https://example1.com',
    'https://example2.com',
    'https://example3.com',
  ];

  const results = await fetchBatchMetadata(urls);

  return (
    <div className="grid gap-4 md:grid-cols-2">
      {results.map((result) => (
        result.success && result.data ? (
          <LinkPreviewCard key={result.url} metadata={result.data} />
        ) : (
          <LinkPreviewFallback 
            key={result.url} 
            url={result.url} 
            error={result.error} 
          />
        )
      ))}
    </div>
  );
}

Performance Optimizations

1. Aggressive Caching

// In your fetch calls
{
  next: { 
    revalidate: 86400,  // 24 hours for static content
    tags: ['link-preview', url]  // For on-demand revalidation
  }
}

2. Parallel Fetching

// Fetch multiple previews in parallel
const urls = ['url1', 'url2', 'url3'];

// ❌ Sequential (slow)
const results = [];
for (const url of urls) {
  results.push(await fetchMetadata(url));
}

// ✅ Parallel (fast)
const results = await Promise.all(
  urls.map(url => fetchMetadata(url))
);

3. Prefetching on Hover

// Client component for hover prefetching
'use client';

import { useCallback } from 'react';
import { prefetchMetadata } from '@/actions/metadata';

export function PrefetchTrigger({ 
  url, 
  children 
}: { 
  url: string; 
  children: React.ReactNode;
}) {
  const handleMouseEnter = useCallback(() => {
    prefetchMetadata(url);
  }, [url]);

  return (
    <div onMouseEnter={handleMouseEnter}>
      {children}
    </div>
  );
}

Accessibility Checklist

  • ✅ Proper ARIA labels on interactive elements
  • ✅ Keyboard navigation support (focus states)
  • ✅ Color contrast meets WCAG AA standards
  • ✅ Loading states announced to screen readers
  • ✅ Alt text for images
  • ✅ Reduced motion support

Conclusion

You now have a production-ready link preview system that:

  • Fetches metadata server-side, avoiding CORS issues
  • Streams with Suspense for optimal loading UX
  • Caches responses for performance
  • Handles errors gracefully
  • Supports multiple sizes and configurations
  • Is fully accessible and responsive

The combination of Next.js 15 Server Components and a reliable metadata API like Katsau makes building professional link previews straightforward.


Ready to add link previews to your app? Get your free Katsau API key — 1,000 requests/month free, no credit card required.

Ready to build?

Try Katsau API

Extract metadata, generate link previews, and monitor URLs with our powerful API. Start free with 1,000 requests per month.