How to create a link preview with Next.js, Prisma and Tailwindcss
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:
- Tailwindcss - for styling
- Prisma ORM - for caching
- Puppeteer - for getting the image previews
- Axios - for fetching the preview (optional)
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.
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