The internet is full of links that lead to other places vying for your attention.

Wouldn't it be nice if we knew where those links took us before we clicked them?

Rich link previews give us instant context without leaving our current flow. They transform basic URLs into interactive elements that boost engagement by surfacing key metadata upfront.

  • Is that article worth reading?
  • Is that YouTube video clickbait?
  • Is that Tweet a banger?

You don't have to guess when a link gets presented with a small preview.

And while some platforms (like Instagram) might break their OG tag support, we can build better, more reliable experiences in our own apps. Or even in Telegram chats.

Let me show you how to implement robust link previews using the free metadata.vision API and React Server Components (RSC).

metadata.visionGet OG metadata for any website with a simple API.metadata.vision

You have a prime example right here. Which link would you rather click?

Demo of what we’re building

Getting the data

Let’s start by creating a server function that will fetch the OG metadata for a link.

1async function getMetadata({ site }: { site: string }) {
2 try {
3 const req = await fetch(`https://og.metadata.vision/${site}`, {
4 next: { revalidate: 60 * 60 * 24 }, // Cache for 24 hours with Next.js
5 });
6 const response = await req.json();
7 return response.data;
8 } catch (error) {
9 console.error(error);
10 throw new Error(`Failed to fetch metadata for ${site}`);
11 }
12}

Now we can create a React Server Component that will render the link preview.

  1. By default, we will show a big video preview if the link has a video.
  2. If the link has no video, we will show a big image preview.
  3. If the link has no video or image, we will show a simple link with a favicon next to the title.

Customizing

Let’s give it some props that will let us style it differently depending on what kind of metadata we want to show.

  • noVideo falls back to the image preview.
  • noImage falls back to the simple link with a favicon.
  • compact will make the image or video smaller and show it alongside the title and description.

On smaller screens, the compact prop will not affect the layout as to not cramp the media and text too much.

Final code

Here is the code for our component, styled using Tailwind CSS:

link-preview.tsx
1import React from "react";
2import Image from "next/image";
3import Link from "next/link";
4import { clsx } from "clsx";
5import { twMerge } from "tailwind-merge";
6
7// Use clsx and tailwind-merge for handling conditional classnames
8const tw = (initial: any, ...args: any[]) => twMerge(clsx(initial, ...args));
9
10type LinkPreviewProps = {
11 url: string;
12 noVideo?: boolean;
13 noImage?: boolean;
14 compact?: boolean;
15};
16
17type MediaProps = {
18 src: string;
19 compact: boolean;
20};
21
22const MediaWrapper = ({ children, compact }: { children: React.ReactNode; compact: boolean }) => (
23 <span
24 className={tw(
25 compact
26 ? "border-b border-border sm:relative sm:border-b-0 sm:border-r sm:border-border"
27 : "w-full border-b border-border"
28 )}
29 >
30 {children}
31 </span>
32);
33
34const PreviewImage = ({ src, compact }: MediaProps) => (
35 <Image
36 src={src}
37 alt=""
38 width={compact ? 256 : 1200}
39 height={compact ? 256 : 630}
40 className={tw("h-full w-full", compact && "sm:object-cover sm:object-center")}
41 />
42);
43
44const PreviewVideo = ({ src, compact }: MediaProps) => (
45 <video
46 src={src}
47 width="100%"
48 height="auto"
49 muted
50 playsInline
51 loop
52 autoPlay
53 className={tw("h-full w-full", compact && "sm:object-cover sm:object-center")}
54 />
55);
56
57const TitleAndDescription = ({
58 metadata,
59 compact,
60 domainOnly,
61 restOfTheUrl,
62 noImage
63}: {
64 metadata: any;
65 compact: boolean;
66 domainOnly: string;
67 restOfTheUrl: string;
68 noImage: boolean;
69}) => (
70 <span
71 className={tw(
72 "flex h-full flex-col justify-between p-4 pb-2.5",
73 compact && "min-w-0 sm:flex sm:h-full sm:flex-col sm:justify-center sm:px-4 sm:py-4 sm:pb-2.5"
74 )}
75 >
76 <span>
77 <span className="text-pretty block font-bold text-lg leading-tight transition">
78 {(!metadata.image || noImage) && metadata.logo && (
79 <span className="block pb-2">
80 <Image
81 alt=""
82 src={metadata.logo}
83 width={28}
84 height={28}
85 className="-ml-0.5 inline-block rounded-md"
86 />
87 </span>
88 )}
89 <span>{metadata.title}</span>
90 </span>
91 <span className="text-pretty block pt-1 text-base leading-[1.35]">
92 {metadata.description}
93 </span>
94 </span>
95 <span className="flex w-full overflow-hidden pt-2 font-mono text-sm opacity-60">
96 <span className="inline-block transition group-hover:text-blue-500">
97 {domainOnly}
98 </span>
99 {restOfTheUrl !== "/" && (
100 <span className="inline-block w-3/4 translate-x-2 overflow-hidden text-ellipsis whitespace-nowrap opacity-0 transition group-hover:translate-x-0 group-hover:opacity-100">
101 {restOfTheUrl}
102 </span>
103 )}
104 </span>
105 </span>
106);
107
108export async function LinkPreview({ url, noVideo, noImage, compact }: LinkPreviewProps) {
109 const metadata = await getMetadata({ site: url });
110
111 // Fallback to a regular link if there is no metadata
112 if (!metadata) {
113 return (
114 <a href={url} target="_blank">
115 {url}
116 </a>
117 );
118 }
119
120 const { hostname: domainOnly, pathname: restOfTheUrl } = new URL(url);
121 const showImage = !noImage && metadata.image && (!metadata.video || noVideo);
122 const showVideo = !noVideo && !noImage && metadata.video;
123
124 return (
125 <Link
126 className={tw(
127 "group flex flex-col overflow-hidden rounded-2xl border border-border ring-blue-500 transition hover:border-blue-500 hover:ring-2 active:scale-[0.98]",
128 compact && "sm:grid sm:grid-cols-[10rem,1fr]"
129 )}
130 href={url}
131 target="_blank"
132 title={url}
133 >
134 {showImage && metadata.image && (
135 <MediaWrapper compact={!!compact}>
136 <PreviewImage src={metadata.image} compact={!!compact} />
137 </MediaWrapper>
138 )}
139 {showVideo && metadata.video && (
140 <MediaWrapper compact={!!compact}>
141 <PreviewVideo src={metadata.video} compact={!!compact} />
142 </MediaWrapper>
143 )}
144 <TitleAndDescription
145 metadata={metadata}
146 compact={!!compact}
147 domainOnly={domainOnly}
148 restOfTheUrl={restOfTheUrl}
149 noImage={!!noImage}
150 />
151 </Link>
152 );
153}
154

This should give you a good base to work with. Tweak the styles to your liking or remove Tailwind altogether.

If you found this useful, follow me on Twitter or subscribe to the newsletter.


Thanks to Jakub Ziemba and Brandon Johnson for feedback on an earlier draft of this post.

get notified when i write something new or launch a new project right into your inbox.

or

subscribe on telegram

Thanks for your attention.