Guillermo de la Puente

Unfurl: how to display link previews, like Notion shows web bookmarks, with Next.js

Development

Published on

RSS Feed RSS Feed

Check this out:

https://guillermodlpa.com

⬆️ This link preview box is pretty cool, isn’t it? ⬆️ 

It renders a link to my homepage, but pulling meta information from it: title, description, image (I don’t have) and favicon. If you have a website reading content from a CMS, like a blog, you might want to do this. I first saw it when using Notion, and then totally got into using link previews.


Unfurl

Unfurling the sails. Source
Unfurling the sails. Source

Unfurl, synonym of unfold, is defined by Oxford Languages Dictionary as “when something that is curled or rolled tightly unfurls, or you unfurl it, it opens”. It seems it was commonly used before, referring mostly to leaves or ship sails. But in recent years, this word has had a comeback.

Probably because we now use unfurl as a jargon verb referring to the process of obtaining additional information about a URL for the purpose of showing a preview.

And that’s what we’re gonna implement here.


Part 1. Unfurling a URL in an API route

With Next.js, we make an endpoint that takes the URL to unfurl, fetches its details, and returns them.

We’ll use the library unfurl.js to keep our code simple and avoid needing to traverse HTML ourselves.

We’ll also cache the response with the Cache-Control header, so that the CDN can cache the result and return it on subsequent calls right away, without unfurling the URL again.

For this example, we’re interested in the page meta title, meta description, favicon and social image, and we’ll return them in a response with this shape:

{
  title: string | null,
  description: string | null,
  favicon: string | null,
  imageSrc: string | null,
}

The route implementation is very simple! Check it out here:

// pages/api/unfurl/[url].ts

import { unfurl } from "unfurl.js";

const CACHE_RESULT_SECONDS = 60 * 60 * 24; // 1 day

export default async function handle(req: NextApiRequest, res: NextApiResponse) {
  const url = req.query.url;

  // a bit of validation...
  if (!url || typeof url !== "string") {
    return res.status(400).json({ error: "A valid URL string is required" });
  }
	
  return unfurl(url)
    .then((unfurlResponse) => {
      return res
        .setHeader("Cache-Control", `public, max-age=${CACHE_RESULT_SECONDS}`)
        .json({
	        title: unfurlResponse.title ?? null,
	        description: unfurlResponse.description ?? null,
	        favicon: unfurlResponse.favicon ?? null,
	        imageSrc: unfurlResponse.open_graph?.images?.[0]?.url ?? null,
	      });
    })
    .catch((error) => {
      if (error?.code === "ENOTFOUND") {
        return res.status(404).json({ error: "Not found" });
      }
      console.error(error);
      throw new Error(error);
    });
}

You can view the full implementation for this website here: https://github.com/guillermodlpa/site/blob/2887fa4b6e1ba91c6b6ee64a8b740e8bf8331290/pages/api/bookmark/[url].ts

Let’s make a component that requests the unfurled URL details, and renders the success, error and loading states.

Also, let’s separate the fetching into a custom hook, so our React component is more clean and maintainable.

export default function LinkPreview({ url }: { url: string }) {
  const { data, status } = useUnfurlUrl(url);

  if (status === "error") {
    return <ErrorFallback url={url} />;
  }
  if (status === "success") {
    return <UnfurledUrlPreview url={url} urlData={data} />;
  }
  return <LoadingSkeleton />;
}

The hook’s implementation can be very simple. In my case, just using useState and Node fetch:

type RequestStatus = "iddle" | "loading" | "success" | "error";

type UrlData = {
  title: string;
  description: string | null;
  favicon: string | null;
  imageSrc: string | null;
};

function useUnfurlUrl(url: string) {
  const [status, setStatus] = useState<RequestStatus>("iddle");
  const [data, setData] = useState<null | UrlData>(null);

  useEffect(() => {
    setStatus("loading");

    const encoded = encodeURIComponent(url);
    fetch(`/api/bookmark/${encoded}`)
      .then(async (res) => {
        if (res.ok) {
          const data = await res.json();
          setData(data);
          setStatus("success");
        } else {
          setStatus("error");
        }
      })
      .catch((error) => {
        console.error(error);
        setStatus("error");
      });
  }, [url]);

  return { status, data };
}

And finally, you may implement each view as you see fit! Me, for this website, I’m using Chakra UI so I leverage its components like LinkBox, Skeleton, Flex, Text, etc.

You can check out my full implementation here: https://github.com/guillermodlpa/site/blob/2887fa4b6e1ba91c6b6ee64a8b740e8bf8331290/features/blogPost/NotionPageRenderer/components/BookmarkBlock.tsx

Cheers!

Back to all posts