Sign in
Log inSign up
How to create a link preview with Next.js, Prisma and Tailwindcss

How to create a link preview with Next.js, Prisma and Tailwindcss

Daniel Olavio Ferreira's photo
Daniel Olavio Ferreira
·Mar 13, 2022·

7 min read

Link preview gif

Intro and requirements

This link preview is a simple yet very elegant touch to add to any blog/portfolio. So without further ado, let's get started. The first thing we'll need is a Next.js project, since we will be using their api routes, but this would also work with any other backend infrastructure. We will need to install the following packages to enable this:

Interface

Let's start working on the CustomLink component, that will be the one that will be used to create the preview. Will need to create a simple component with the following structure:

import React from "react";

export default function CustomLink({ children, href }) {
  let [imagePreview, setImagePreview] = React.useState("");
  let [isHovering, setIsHovering] = React.useState(false);

  return (
    <span>
      <a href={href}>{children}</a>
      {isHovering && (
        <a href={href}>
          <span>
            {imagePreview ? (
              <img src={imagePreview} alt={children} />
            ) : (
              <span>Loading</span>
            )}
          </span>
        </a>
      )}
    </span>
  );
}

Now we can apply the styling, for that we will be using Tailwindcss. Most of the styling is pretty straight forward, but there is one piece that I will explain more in depth, that is the positioning of the preview container. The following are the the simple styling I mentioned:

export default function CustomLink({ children, href }) {
  let [imagePreview, setImagePreview] = React.useState("");
  let [isHovering, setIsHovering] = React.useState(false);

  return (
    <span className="relative z-10 inline-block">
      <a href={href} className={`${isHovering && "underline"}`}>
        {children}
      </a>
      {isHovering && (
        <a href={href}>
          <span>
            {imagePreview ? (
              <img
                className="w-36 h-24 rounded-md bg-white shadow-lg object-cover object-top"
                src={imagePreview}
                alt={children}
              />
            ) : (
              <span className="w-36 h-24 rounded-md bg-white shadow-lg flex items-center justify-center">
                Loading
              </span>
            )}
          </span>
        </a>
      )}
    </span>
  );
}

Now for the preview image container. We need to always have it centered to the text length. So for that we will be doing a bit of transforming and translating. We will make it so that its left is in the center of the text, and then will move it by half of its width to the left, this way centering it. The classes will look like this:

export default function CustomLink({ children, href }) {
  let [imagePreview, setImagePreview] = React.useState("");
  let [isHovering, setIsHovering] = React.useState(false);

  return (
    <span className="relative z-10 inline-block">
      <a href={href} className={`${isHovering && "underline"}`}>
        {children}
      </a>
      {isHovering && (
        <a href={href}>
          <span className="w-36 h-28 absolute -top-32 left-1/2 transform -translate-x-[4.5rem] translate-y-8 flex items-start justify-center">
            {imagePreview ? (
              <img
                className="w-36 h-24 rounded-md bg-white shadow-lg object-cover object-top"
                src={imagePreview}
                alt={children}
              />
            ) : (
              <span className="w-36 h-24 rounded-md bg-white shadow-lg flex items-center justify-center">
                Loading
              </span>
            )}
          </span>
        </a>
      )}
    </span>
  );
}

One sneaky detail that will be very important in the future is that we are making our container higher than the image itself. This is so that when we are dealing with the hovering state, we don't trigger the onMouseLeave event when moving from the text to the preview image.

Now we have to deal with the actual hovering states, to know when to show, and when not to show the image. For this we will create a few functions that will handle this, and assign then to the correct component events.

export default function CustomLink({ children, href }) {
  let [imagePreview, setImagePreview] = React.useState("");
  let [isHovering, setIsHovering] = React.useState(false);
  let inImagePreview = false;
  let inLink = false;

  let handleMouseEnterImage = () => {
    inImagePreview = true;
    setIsHovering(true);
  };

  let handleMouseLeaveImage = () => {
    inImagePreview = false;
    setIsHovering(inLink);
  };

  let handleMouseEnterLink = () => {
    inLink = true;
    setIsHovering(true);
  };

  let handleMouseLeaveLink = () => {
    inLink = false;
    setIsHovering(inImagePreview);
  };

  return (
    <span className="relative z-10 inline-block">
      <a
        href={href}
        className={`${isHovering && "underline"}`}
        onMouseEnter={handleMouseEnterLink}
        onMouseLeave={handleMouseLeaveLink}
        onFocus={handleMouseEnterLink}
        onBlur={handleMouseLeaveLink}>
        {children}
      </a>
      {isHovering && (
        <a href={href}>
          <span
            className="w-36 h-28 absolute -top-32 left-1/2 transform -translate-x-[4.5rem] translate-y-8 flex items-start justify-center"
            onMouseLeave={handleMouseLeaveImage}
            onMouseEnter={handleMouseEnterImage}
            onFocus={handleMouseEnterImage}
            onBlur={handleMouseLeaveImage}>
            {imagePreview ? (
              <img
                className="w-36 h-24 rounded-md bg-white shadow-lg object-cover object-top"
                src={imagePreview}
                alt={children}
              />
            ) : (
              <span className="w-36 h-24 rounded-md bg-white shadow-lg flex items-center justify-center">
                Loading
              </span>
            )}
          </span>
        </a>
      )}
    </span>
  );
}

If you followed everything correctly, you will have the following result now.

mile stone 1

To improve it a little, I'll change the Loading text to a Spinner component. That is it for the UI, now let's work on the preview image generation.

Image Generation

First we are going to create a preview.js file in our api folder. This is the function that will access the website and capturing a preview image from it. The idea is, we will use Puppeteer to access the website and take a screenshot and return this image as a base64 that can be rendered in the frontend. The function will look like this:

import puppeteer from "puppeteer";

export default async function handler(req, res) {
  try {
    let { url } = req.query;

    let image = await getImageBase64(url);

    res.status(200).json({
      image,
    });
  } catch (error) {
    res.status(500).json({
      error: JSON.stringify(error),
    });
  }
}

let getImageBase64 = async (url) => {
  let browser = await puppeteer.launch();
  let page = await browser.newPage();
  await page.goto(url);
  let image = await page.screenshot({ encoding: "base64" });
  await browser.close();
  return image;
};

Now we just need to go back to the frontend and fetch from this route. We will make the request inside of a useEffect, so that we fetch when the component is mounted and we will clear the imagePreview state on unmount, to prevent wasting resources. Also we need to change the img source type to receive a base64 image.

export default function CustomLink({ children, href }) {
  let [imagePreview, setImagePreview] = React.useState("");
  let [isHovering, setIsHovering] = React.useState(false);
  let inImagePreview = false;
  let inLink = false;

  let handleMouseEnterImage = () => {
    inImagePreview = true;
    setIsHovering(true);
  };

  let handleMouseLeaveImage = () => {
    inImagePreview = false;
    setIsHovering(inLink);
  };

  let handleMouseEnterLink = () => {
    inLink = true;
    setIsHovering(true);
  };

  let handleMouseLeaveLink = () => {
    inLink = false;
    setIsHovering(inImagePreview);
  };

  let handleFetchImage = async (url) => {
    let {
      data: { image },
    } = await axios.get("localhost:3000/api/preview", {
      params: { url },
    });
    setImagePreview(image);
  };

  React.useEffect(() => {
    handleFetchImage(href);

    return () => setImagePreview("");
  }, [href]);

  return (
    <span className="relative z-10 inline-block">
      <a
        href={href}
        className={`${isHovering && "underline"}`}
        onMouseEnter={handleMouseEnterLink}
        onMouseLeave={handleMouseLeaveLink}
        onFocus={handleMouseEnterLink}
        onBlur={handleMouseLeaveLink}>
        {children}
      </a>
      {isHovering && (
        <a href={href}>
          <span
            className="w-36 h-28 absolute -top-32 left-1/2 transform -translate-x-[4.5rem] translate-y-8 flex items-start justify-center"
            onMouseLeave={handleMouseLeaveImage}
            onMouseEnter={handleMouseEnterImage}
            onFocus={handleMouseEnterImage}
            onBlur={handleMouseLeaveImage}>
            {imagePreview ? (
              <img
                className="w-36 h-24 rounded-md bg-white shadow-lg object-cover object-top"
                src={`data:image/jpeg;base64, ${imagePreview}`}
                alt={children}
              />
            ) : (
              <span className="w-36 h-24 rounded-md bg-white shadow-lg flex items-center justify-center">
                <Spinner />
              </span>
            )}
          </span>
        </a>
      )}
    </span>
  );
}

This will already give us a working preview image, to improve it even more, we are going to use Prisma as a cache layer in our application.

Cache

We will now add Prisma to the api route we previously created to use it as an image cache layer. It's important to note that I will no go in depth how to setup Prisma with Next.js, there are great examples in the Prisma docs for that.

First we need to create our schema file, this will be what determine what tables and columns our database will have. I will create a very simple one, using Postgres, but feel free to elaborate more on the columns used. Our schema will look like the following:

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgres"
  url      = "postgresql://USER:PASSWORD@HOST:PORT/DATABASE"
}

model Image {
  url   String @id @unique
  image String
}

Our preview route will need some adjustments to use this table, we will need to create two new functions, one to check for the cached images, and one to save the generated ones.

export default async function handler(req, res) {
  try {
    let { url } = req.query;

    let image = await getImageBase64(url);

    res.status(200).json({
      image,
    });
  } catch (error) {
    console.log(error);
    res.status(500).json({
      error: JSON.stringify(error),
    });
  }
}

let getImageBase64 = async (url) => {
  let cachedImage = await getCachedImage(url);
  if (cachedImage) return cachedImage;

  let browser = await puppeteer.launch();
  let page = await browser.newPage();
  await page.goto(url);
  let image = await page.screenshot({ encoding: "base64" });
  await browser.close();

  await cacheImage(url, image);

  return image;
};

let getCachedImage = async (url) => {
  let { image } = await prisma.image.findUnique({ where: { url } });
  return image;
};

let cacheImage = async (url, image) => {
  await prisma.image.create({ data: { url, image } });
};

Conclusion

So there you have it, a very simple and elegant link preview component, with caching. Some things to point out is that there are many improvements that can be made in this component, but for the sake of simplicity I did not explain them here. Some of them are:

  • Use the Next Image component
  • Make the component more generic and agnostic to how the image is fetched
  • Maybe use a in memory database, such as Redis to improve performance
  • Upload the images to an CDN to not have to pass the entire base64 encoded image via HTTP