Link previews
A link preview is the card with a title, a description and an image that shows for a link in a message. This page covers how the composer loads a preview while the user types, what's sent with the message, and how the message list shows it.
What users get
The user types "Look at https://example.com/launch". When they stop typing, a card appears above the input with the page's image, title and description, and the URL. They can dismiss it with its remove button. When they send, the message in the list shows a card for the link above the text, and tapping the card opens the page.
![]() | ![]() |
How it works across the layers
- State. The composer's
linkPreviewsManagerfinds the links in the text, loads a preview for each, and keeps them in its store. Sending adds the loaded previews to the message as attachments. - UI. In the composer,
MessageInputHeaderViewrenders the previews above the input asLinkPreviewCards. In the message list,Attachmentrenders each preview withUrlPreview, or withURLPreviewCompact. - Layout. Not involved.
From typing to sending
- Each text change starts a wait of
linkPreviews.debounceURLEnrichmentMs, 1500 ms by default. When the user stops typing for that long,linkPreviews.findURLFnfinds the URLs in the text. - Each new URL gets a preview with status
loading, and the manager asks the server for the page's data withclient.getOG({ url }). The preview becomesloaded, orfailedwhen the request fails. When the text has no URL left, all previews are cleared. - Dismissing a preview calls
LinkPreviewsManager.dismissPreview(url). Its status becomesdismissed, andlinkPreviews.onLinkPreviewDismissedis called. - On send, the composition middleware adds every loaded preview to the message's attachments, and tells the server not to add previews of its own (
skip_enrich_url). It also setsskip_enrich_urlwhen the user dismissed a preview, but only when the message carries attachments. A text-only message whose preview was dismissed goes out without the flag, and the server adds the card. - If a preview is still loading when the user sends, no previews are sent, and the server adds them while it saves the message.
When the user lacks the send-links capability in the channel and the text contains a link, sending shows an alert, "Links are disabled", and nothing is sent. Message composer lists the statuses.
In the composer and the message list
The composer shows only loaded previews, and only the first of them. The others are still sent. The card shows the image, the title, the description and the URL, and its remove button dismisses the preview. A preview that is loading or failed shows nothing.
In the message list, an attachment with og_scrape_url or title_link is a link preview. Attachment renders it with UrlPreview: the image, with a play indicator when the link is a video, then the title, the description and the URL. With urlPreviewType="compact" on Channel or Thread, it renders URLPreviewCompact instead, a row with a small thumbnail. Tapping a card opens og_scrape_url, or the image's URL when there is none.
A message that also carries files, images or videos shows no link cards, and the link stays a link in the text.
Turning it on or off
Link previews are on by default. Two sides decide:
- The client:
messageComposer.linkPreviews.enabled,trueby default. It decides whether the composer loads and shows previews. - The server: the channel type's
url_enrichmentsetting. It decides whether the server adds previews to messages. With it off, the composer doesn't load previews either.
To stop the composer from loading previews:
client.config.set({
messageComposer: { linkPreviews: { enabled: false } },
});The server still adds previews to sent messages while url_enrichment is on, so messages keep their cards. Only the composer stops showing them. To send messages without cards, turn url_enrichment off for the channel type. Configuration lists every setting both sides decide.
Customizing
Change how links are found and loaded
import { StreamChat } from "stream-chat";
import type { LinkPreview } from "stream-chat";
const client = StreamChat.getInstance("YOUR_API_KEY");
client.config.set({
messageComposer: {
linkPreviews: {
// Only links to your own domain get a preview.
findURLFn: (text: string) =>
Array.from(
text.matchAll(/https:\/\/example\.com\/\S*/g),
([url]) => url,
),
onLinkPreviewDismissed: (linkPreview: LinkPreview) => {
console.log(`Dismissed ${linkPreview.og_scrape_url}`);
},
},
},
});debounceURLEnrichmentMs is read only when a composer is built, so set it before the client creates composers, for example in the options you pass to useCreateChatClient.
Replace the previews in the composer
The composer's preview list has no override key of its own. MessageInputHeaderView renders it, together with the quoted or edited message and the attachment previews, so a replacement renders those too. This header shows every loaded preview as a line with its title:
import { Pressable, StyleSheet, Text, View } from "react-native";
import { LinkPreviewsManager } from "stream-chat";
import type {
LinkPreviewsManagerState,
MessageComposerState,
} from "stream-chat";
import {
useComponentsContext,
useMessageComposer,
useMessageComposerAPIContext,
useStateStore,
} from "stream-chat-react-native";
import type { ComponentOverrides } from "stream-chat-react-native";
const loadedPreviewsSelector = ({ previews }: LinkPreviewsManagerState) => ({
previews: Array.from(previews.values()).filter(
LinkPreviewsManager.previewIsLoaded,
),
});
const quotedMessageSelector = ({ quotedMessage }: MessageComposerState) => ({
quotedMessage,
});
const LinkPreviewLines = () => {
const { linkPreviewsManager } = useMessageComposer();
const { previews } = useStateStore(
linkPreviewsManager.state,
loadedPreviewsSelector,
);
return (
<>
{previews.map((preview) => (
<View key={preview.og_scrape_url} style={styles.line}>
<Text numberOfLines={1} style={styles.title}>
{preview.title ?? preview.og_scrape_url}
</Text>
<Pressable
onPress={() =>
linkPreviewsManager.dismissPreview(preview.og_scrape_url)
}
>
<Text>Remove</Text>
</Pressable>
</View>
))}
</>
);
};
const ComposerHeader = () => {
const { AttachmentUploadPreviewList, Reply } = useComponentsContext();
const messageComposer = useMessageComposer();
const { quotedMessage } = useStateStore(
messageComposer.state,
quotedMessageSelector,
);
const { clearEditingState } = useMessageComposerAPIContext();
const { editedMessage } = messageComposer;
return (
<View style={styles.header}>
{editedMessage ? (
<Reply
mode="edit"
onDismiss={clearEditingState}
quotedMessage={editedMessage}
/>
) : quotedMessage ? (
<Reply
mode="reply"
onDismiss={() => messageComposer.setQuotedMessage(null)}
/>
) : null}
<AttachmentUploadPreviewList />
<LinkPreviewLines />
</View>
);
};
// Pass to `WithComponents`, declared once at module scope.
export const overrides: ComponentOverrides = {
MessageInputHeaderView: ComposerHeader,
};
const styles = StyleSheet.create({
header: { gap: 4, paddingHorizontal: 8 },
line: { alignItems: "center", flexDirection: "row", gap: 8 },
title: { flex: 1 },
});It works in channel, thread and edit composers alike, because useMessageComposer() returns the composer it's rendered in. The SDK's header also hides itself while a voice recording runs, and animates its parts in and out.
Change the card in the message list
UrlPreview and URLPreviewCompact are override keys, and receive the attachment. This one renders the link as a line of text:
import { Linking, Pressable, StyleSheet, Text } from "react-native";
import type {
ComponentOverrides,
URLPreviewProps,
} from "stream-chat-react-native";
const LinkLine = ({ attachment }: URLPreviewProps) => {
const url = attachment.og_scrape_url ?? attachment.title_link;
return (
<Pressable onPress={() => url && Linking.openURL(url)}>
<Text style={styles.link}>{attachment.title ?? url}</Text>
</Pressable>
);
};
// Pass to `WithComponents`, declared once at module scope.
export const overrides: ComponentOverrides = { UrlPreview: LinkLine };
const styles = StyleSheet.create({
link: { padding: 8, textDecorationLine: "underline" },
});The SDK's cards report a tap to onPressMessage on Channel, with the emitter urlPreview and the URL in additionalInfo, and open the page as their default. A card of your own that should keep that reads onPress from useMessageContext() and calls it with the same payload.
Reference
- Attachments:
URLPreview,URLPreviewCompactand theAttachmentcomponent. - Composer UI components:
MessageInputHeaderViewand the composer's other parts. - MessageComposer API:
linkPreviewsManager. - Configuration tree: every
messageComposer.linkPreviewssetting. - Channel:
urlPreviewType.

