Link previews make chat apps feel professional. When users share a link, it automatically unfurls into a rich preview with images, titles, and descriptions—just like WhatsApp, Telegram, or iMessage. In this tutorial, you'll add this feature to a React chat app with Socket.IO.
What We're Building
A real-time chat with:
- Instant link detection as users type
- Preview fetching in the background
- Real-time broadcast to all chat participants
- Graceful loading states and error handling
Project Setup
We'll build on a basic Socket.IO chat. Here's the structure:
chat-app/
├── server/
│ ├── index.ts
│ └── link-preview.ts
├── client/
│ ├── src/
│ │ ├── components/
│ │ │ ├── Chat.tsx
│ │ │ ├── Message.tsx
│ │ │ ├── LinkPreview.tsx
│ │ │ └── MessageInput.tsx
│ │ ├── hooks/
│ │ │ └── useSocket.ts
│ │ └── App.tsx
│ └── package.json
└── package.json
Initialize the Project
mkdir chat-app && cd chat-app
# Server setup
mkdir server && cd server
npm init -y
npm install express socket.io cors dotenv
# Client setup
cd .. && npx create-react-app client --template typescript
cd client
npm install socket.io-client
Server Implementation
Main Server File
// server/index.ts
import express from 'express';
import { createServer } from 'http';
import { Server } from 'socket.io';
import cors from 'cors';
import { fetchLinkPreview } from './link-preview';
const app = express();
app.use(cors());
const server = createServer(app);
const io = new Server(server, {
cors: {
origin: 'http://localhost:3000',
methods: ['GET', 'POST'],
},
});
interface Message {
id: string;
userId: string;
username: string;
text: string;
timestamp: number;
linkPreviews?: LinkPreview[];
}
interface LinkPreview {
url: string;
title: string;
description: string;
image: string | null;
favicon: string | null;
siteName: string | null;
}
// Store messages in memory (use a database in production)
const messages: Message[] = [];
io.on('connection', (socket) => {
console.log('User connected:', socket.id);
// Send existing messages to new user
socket.emit('messages:history', messages.slice(-50));
// Handle new message
socket.on('message:send', async (data: { text: string; username: string }) => {
const message: Message = {
id: `${Date.now()}-${socket.id}`,
userId: socket.id,
username: data.username,
text: data.text,
timestamp: Date.now(),
};
// Broadcast message immediately (fast response)
messages.push(message);
io.emit('message:new', message);
// Extract and fetch link previews in background
const urls = extractUrls(data.text);
if (urls.length > 0) {
// Notify that we're fetching previews
io.emit('message:preview:loading', { messageId: message.id, urls });
// Fetch previews (limit to 3 per message)
const previews = await Promise.all(
urls.slice(0, 3).map((url) => fetchLinkPreview(url))
);
// Update message with previews
message.linkPreviews = previews.filter(Boolean) as LinkPreview[];
// Broadcast preview update
io.emit('message:preview:loaded', {
messageId: message.id,
linkPreviews: message.linkPreviews,
});
}
});
socket.on('disconnect', () => {
console.log('User disconnected:', socket.id);
});
});
function extractUrls(text: string): string[] {
const urlRegex = /https?:\/\/[^\s<]+[^<.,:;"')\]\s]/g;
return text.match(urlRegex) || [];
}
const PORT = process.env.PORT || 4000;
server.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
Link Preview Service
// server/link-preview.ts
import 'dotenv/config';
interface LinkPreview {
url: string;
title: string;
description: string;
image: string | null;
favicon: string | null;
siteName: string | null;
}
// Simple in-memory cache
const cache = new Map<string, { data: LinkPreview; expiry: number }>();
const CACHE_TTL = 1000 * 60 * 60; // 1 hour
export async function fetchLinkPreview(url: string): Promise<LinkPreview | null> {
try {
// Check cache
const cached = cache.get(url);
if (cached && cached.expiry > Date.now()) {
return cached.data;
}
// Fetch from API
const response = await fetch(
`https://api.katsau.com/v1/extract?url=${encodeURIComponent(url)}`,
{
headers: {
'Authorization': `Bearer ${process.env.KATSAU_API_KEY}`,
},
}
);
if (!response.ok) {
console.error(`Failed to fetch preview for ${url}: ${response.status}`);
return null;
}
const { data } = await response.json();
const preview: LinkPreview = {
url,
title: data.title || new URL(url).hostname,
description: data.description || '',
image: data.image || null,
favicon: data.favicon || null,
siteName: data.siteName || new URL(url).hostname,
};
// Cache the result
cache.set(url, { data: preview, expiry: Date.now() + CACHE_TTL });
return preview;
} catch (error) {
console.error(`Error fetching preview for ${url}:`, error);
return null;
}
}
Client Implementation
Socket Hook
// client/src/hooks/useSocket.ts
import { useEffect, useState, useCallback, useRef } from 'react';
import { io, Socket } from 'socket.io-client';
interface Message {
id: string;
userId: string;
username: string;
text: string;
timestamp: number;
linkPreviews?: LinkPreview[];
previewLoading?: boolean;
}
interface LinkPreview {
url: string;
title: string;
description: string;
image: string | null;
favicon: string | null;
siteName: string | null;
}
export function useSocket(serverUrl: string) {
const [isConnected, setIsConnected] = useState(false);
const [messages, setMessages] = useState<Message[]>([]);
const socketRef = useRef<Socket | null>(null);
useEffect(() => {
const socket = io(serverUrl);
socketRef.current = socket;
socket.on('connect', () => setIsConnected(true));
socket.on('disconnect', () => setIsConnected(false));
// Receive message history
socket.on('messages:history', (history: Message[]) => {
setMessages(history);
});
// Receive new message
socket.on('message:new', (message: Message) => {
setMessages((prev) => [...prev, message]);
});
// Preview loading started
socket.on('message:preview:loading', ({ messageId, urls }) => {
setMessages((prev) =>
prev.map((msg) =>
msg.id === messageId ? { ...msg, previewLoading: true } : msg
)
);
});
// Preview loaded
socket.on('message:preview:loaded', ({ messageId, linkPreviews }) => {
setMessages((prev) =>
prev.map((msg) =>
msg.id === messageId
? { ...msg, linkPreviews, previewLoading: false }
: msg
)
);
});
return () => {
socket.disconnect();
};
}, [serverUrl]);
const sendMessage = useCallback((text: string, username: string) => {
socketRef.current?.emit('message:send', { text, username });
}, []);
return { isConnected, messages, sendMessage };
}
Message Component
// client/src/components/Message.tsx
import React from 'react';
import { LinkPreview } from './LinkPreview';
interface MessageProps {
message: {
id: string;
userId: string;
username: string;
text: string;
timestamp: number;
linkPreviews?: Array<{
url: string;
title: string;
description: string;
image: string | null;
favicon: string | null;
siteName: string | null;
}>;
previewLoading?: boolean;
};
isOwnMessage: boolean;
}
export function Message({ message, isOwnMessage }: MessageProps) {
const time = new Date(message.timestamp).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
});
return (
<div
className={`flex flex-col mb-4 ${
isOwnMessage ? 'items-end' : 'items-start'
}`}
>
{/* Username */}
{!isOwnMessage && (
<span className="text-xs text-gray-500 mb-1 ml-1">
{message.username}
</span>
)}
{/* Message bubble */}
<div
className={`max-w-md px-4 py-2 rounded-2xl ${
isOwnMessage
? 'bg-blue-500 text-white rounded-br-md'
: 'bg-gray-100 text-gray-900 rounded-bl-md'
}`}
>
<p className="whitespace-pre-wrap break-words">{message.text}</p>
<span
className={`text-xs ${
isOwnMessage ? 'text-blue-100' : 'text-gray-400'
} float-right mt-1 ml-2`}
>
{time}
</span>
</div>
{/* Link previews */}
{message.previewLoading && (
<div className="mt-2 w-full max-w-md">
<LinkPreviewSkeleton />
</div>
)}
{message.linkPreviews && message.linkPreviews.length > 0 && (
<div className="mt-2 space-y-2 w-full max-w-md">
{message.linkPreviews.map((preview) => (
<LinkPreview key={preview.url} preview={preview} />
))}
</div>
)}
</div>
);
}
function LinkPreviewSkeleton() {
return (
<div className="border rounded-xl overflow-hidden animate-pulse">
<div className="h-32 bg-gray-200" />
<div className="p-3 space-y-2">
<div className="h-4 bg-gray-200 rounded w-3/4" />
<div className="h-3 bg-gray-200 rounded w-full" />
</div>
</div>
);
}
Link Preview Component
// client/src/components/LinkPreview.tsx
import React from 'react';
import { ExternalLink } from 'lucide-react';
interface LinkPreviewProps {
preview: {
url: string;
title: string;
description: string;
image: string | null;
favicon: string | null;
siteName: string | null;
};
}
export function LinkPreview({ preview }: LinkPreviewProps) {
return (
<a
href={preview.url}
target="_blank"
rel="noopener noreferrer"
className="block border border-gray-200 rounded-xl overflow-hidden hover:border-blue-300 transition-colors group"
>
{/* Image */}
{preview.image && (
<div className="h-32 overflow-hidden">
<img
src={preview.image}
alt=""
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
onError={(e) => {
e.currentTarget.parentElement!.style.display = 'none';
}}
/>
</div>
)}
{/* Content */}
<div className="p-3">
{/* Site info */}
<div className="flex items-center gap-2 mb-1">
{preview.favicon && (
<img
src={preview.favicon}
alt=""
className="w-4 h-4 rounded"
onError={(e) => {
e.currentTarget.style.display = 'none';
}}
/>
)}
<span className="text-xs text-gray-500 uppercase tracking-wide">
{preview.siteName}
</span>
<ExternalLink className="w-3 h-3 text-gray-400 ml-auto opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
{/* Title */}
<h4 className="font-semibold text-sm text-gray-900 line-clamp-2 group-hover:text-blue-600 transition-colors">
{preview.title}
</h4>
{/* Description */}
{preview.description && (
<p className="text-xs text-gray-600 mt-1 line-clamp-2">
{preview.description}
</p>
)}
</div>
</a>
);
}
Message Input Component
// client/src/components/MessageInput.tsx
import React, { useState, useCallback } from 'react';
import { Send } from 'lucide-react';
interface MessageInputProps {
onSend: (text: string) => void;
disabled?: boolean;
}
export function MessageInput({ onSend, disabled }: MessageInputProps) {
const [text, setText] = useState('');
const handleSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
if (text.trim() && !disabled) {
onSend(text.trim());
setText('');
}
},
[text, onSend, disabled]
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
},
[handleSubmit]
);
return (
<form onSubmit={handleSubmit} className="flex gap-2 p-4 border-t">
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a message... (paste a URL to see the magic!)"
disabled={disabled}
className="flex-1 resize-none border rounded-xl px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100"
rows={1}
/>
<button
type="submit"
disabled={!text.trim() || disabled}
className="px-4 py-2 bg-blue-500 text-white rounded-xl hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<Send className="w-5 h-5" />
</button>
</form>
);
}
Main Chat Component
// client/src/components/Chat.tsx
import React, { useEffect, useRef, useState } from 'react';
import { useSocket } from '../hooks/useSocket';
import { Message } from './Message';
import { MessageInput } from './MessageInput';
export function Chat() {
const [username, setUsername] = useState('');
const [isJoined, setIsJoined] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const { isConnected, messages, sendMessage } = useSocket(
'http://localhost:4000'
);
// Auto-scroll to bottom
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const handleJoin = (e: React.FormEvent) => {
e.preventDefault();
if (username.trim()) {
setIsJoined(true);
}
};
const handleSend = (text: string) => {
sendMessage(text, username);
};
if (!isJoined) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<form onSubmit={handleJoin} className="bg-white p-8 rounded-2xl shadow-lg">
<h1 className="text-2xl font-bold mb-4">Join Chat</h1>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Enter your name"
className="w-full border rounded-xl px-4 py-2 mb-4 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
type="submit"
disabled={!username.trim()}
className="w-full bg-blue-500 text-white py-2 rounded-xl hover:bg-blue-600 disabled:opacity-50 transition-colors"
>
Join
</button>
</form>
</div>
);
}
return (
<div className="min-h-screen flex flex-col bg-gray-50">
{/* Header */}
<header className="bg-white border-b px-4 py-3 flex items-center justify-between">
<h1 className="font-semibold">Chat Room</h1>
<div className="flex items-center gap-2">
<div
className={`w-2 h-2 rounded-full ${
isConnected ? 'bg-green-500' : 'bg-red-500'
}`}
/>
<span className="text-sm text-gray-500">
{isConnected ? 'Connected' : 'Disconnected'}
</span>
</div>
</header>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4">
{messages.map((message) => (
<Message
key={message.id}
message={message}
isOwnMessage={message.username === username}
/>
))}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<MessageInput onSend={handleSend} disabled={!isConnected} />
</div>
);
}
Optimizations
1. Debounce Preview Requests
Don't fetch previews for every keystroke:
// In MessageInput, show preview as user types
const [previewUrls, setPreviewUrls] = useState<string[]>([]);
const debouncedText = useDebounce(text, 500);
useEffect(() => {
const urls = extractUrls(debouncedText);
setPreviewUrls(urls.slice(0, 3));
}, [debouncedText]);
2. Preview Queue
Don't overwhelm your server with preview requests:
// server/preview-queue.ts
import Bottleneck from 'bottleneck';
const limiter = new Bottleneck({
maxConcurrent: 5, // 5 concurrent requests
minTime: 100, // 100ms between requests
});
export function queuePreviewFetch(url: string): Promise<LinkPreview | null> {
return limiter.schedule(() => fetchLinkPreview(url));
}
3. Persistent Cache with Redis
import { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
async function getCachedPreview(url: string): Promise<LinkPreview | null> {
const cached = await redis.get(`preview:${url}`);
return cached ? JSON.parse(cached) : null;
}
async function cachePreview(url: string, preview: LinkPreview): Promise<void> {
await redis.set(`preview:${url}`, JSON.stringify(preview), 'EX', 86400);
}
Running the App
- Create
.envin server directory:
KATSAU_API_KEY=your_api_key_here
PORT=4000
- Start the server:
cd server
npx ts-node index.ts
- Start the client:
cd client
npm start
- Open http://localhost:3000 and start chatting!
What's Next?
You now have a fully functional chat with link previews. To make it production-ready:
- Add user authentication
- Store messages in a database
- Implement rooms/channels
- Add typing indicators
- Deploy to production
Conclusion
Link previews transform a basic chat into a professional messaging experience. By handling preview fetching on the server and broadcasting updates via WebSockets, you get real-time unfurling without blocking the UI.
The key takeaways:
- Send messages immediately, fetch previews in background
- Use WebSockets to broadcast preview updates to all users
- Cache aggressively to avoid redundant API calls
- Handle errors gracefully with fallback displays
Building a chat app? Get your free Katsau API key and add link previews in minutes!
Try Katsau API
Extract metadata, generate link previews, and monitor URLs with our powerful API. Start free with 1,000 requests per month.