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.
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.
Try Katsau API
Extract metadata, generate link previews, and monitor URLs with our powerful API. Start free with 1,000 requests per month.