Adding Link Previews to Real-Time Chat: Socket.IO + React Tutorial
Tutorial10 min read

Adding Link Previews to Real-Time Chat: Socket.IO + React Tutorial

Build WhatsApp-style link previews for your chat app. Complete tutorial with Socket.IO, React, and real-time unfurling as users type.

Katsau

Katsau Team

December 23, 2025

Share:

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

  1. Create .env in server directory:
KATSAU_API_KEY=your_api_key_here
PORT=4000
  1. Start the server:
cd server
npx ts-node index.ts
  1. Start the client:
cd client
npm start
  1. 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:

  1. Send messages immediately, fetch previews in background
  2. Use WebSockets to broadcast preview updates to all users
  3. Cache aggressively to avoid redundant API calls
  4. Handle errors gracefully with fallback displays

Building a chat app? Get your free Katsau API key and add link previews in minutes!

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.