Amblem
Furkan Baytekin

Writing a Watermark API in Next.js

Building a Simple Watermark API with Next.js and Sharp

Writing a Watermark API in Next.js
98
9 minutes

IMPORTANT NOTE: DO NOT WATERMARK IMAGES YOU DO NOT OWN.

Recently, I saw a question on X about how to add a watermark to an image using Next.js. Should we do it on frontend or backend? I gave a quick answer on X, but I thought it would be a good idea to write a blog post and create a GitHub repository to showcase how to do it.

Here is the repository: https://github.com/Elagoht/mark-your-water

The Plan

We must handle it on backend. Handling it on frontend is not a good idea. Because the source image will be loaded on user’s browser. It’s not an intended behavior. And not a good practice for non-javascript users, web crawlers, scrapers, bots, etc. Watermarking on the backend is the way to go.

NOTE: This api will fetch, create and return an image in every request. You can cache the image on the server side to improve performance. Think about saving them on a storage or use Next.js’s <Image/> component.

I assume you already have a Next.js project. Because the question was asked in a Next.js context. In the repository, I used create-next-app to create a new project.

  1. We will implement a class to get source image metadata to see the original dimensions of the image. We will need to create a new image with the exact same dimensions as the source image.
  2. Then we will use a div containing an image element with the source image. We will make the div relatively positioned create a text with semi-transparent background.
  3. After that, we will render it and create a BLOB response via Next.js’s ImageResponse class.
  4. The rest is about creating route handling.

Source Image Metadata

I created a simple class to handle everything about the source image. Yes, we could have use seperate classes for each of the operations. But this is an example project that we will not extend with more features. So I thought we can keep it simple.

PS: I realized that I forgot how to write javascript. I used lots of typescript specific syntax in first commits. Here is the final and javascript version:

I will use comment lines to explain the code. So keep reading carefully.

Watermark Class

javascript
// Image Response will create a BLOB response. Supplied by Next.js import { ImageResponse } from 'next/og'; // I didn't use a JSX syntax. So I used createElement to create new elements. import { createElement } from 'react'; /** * sharp is a library to handle images, we will use it to get metadata of the source image * Keep it mind, the ImageResponse may require this library on some more advanced cases. * Don't worry, it will say if needed. In our case, it is not needed by ImageResponse. */ import sharp from 'sharp'; class Watermark { /** * @param {string} imageUrl - The URL of the image * @returns {Promise<{ width: number, height: number }>} - The size of the image */ static async getImageSize(imageUrl) { try { /** * We need to fetch image to get its size. I used the node fetch to do this. * If you prefer a library like axios, you can use it instead. */ const res = await fetch(imageUrl, { headers: { // This tells the server that we are a browser. 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3' } }); /** * If the image is not found, we will throw an error. * We will catch it on route handler. */ if (!res.ok) { throw new Error(`Failed to fetch image: ${res.statusText}`); } /** * We need to check if the content type is an image. * If not, we will throw an error. */ const contentType = res.headers.get('content-type'); if (!contentType || !contentType.startsWith('image/')) { throw new Error('Fetched content is not an image'); } /** * WebP format is not supported. * If the image is a WebP, we will throw an error again. */ if (contentType === 'image/webp') { throw new Error('WebP format is not supported'); } /** * Sharp needs a buffer to work on. Our response is * an image file here. We checked it before. * So we can convert it to a buffer: * Created an array buffer, then converted it to a buffer. */ const arrayBuffer = await res.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); // Here we get the metadata of the image. const metadata = await sharp(buffer).metadata(); // Check if the image width and height are gathered successfully. if (!metadata.width || !metadata.height) { /** * We use early return and throw as you see. * This is a simple way to handle the error. */ throw new Error('Image size not found'); } // Return the size of the image as an object. return { width: metadata.width, height: metadata.height }; } catch (error) { // If something goes wrong, we will throw an error. throw new Error(`Error getting image size: ${ /** * ! NOTE: * This is not a good practice for production. * Always create a fallback error message. * This will help you to debug the error and * keep it mind the error message may contain * code snippets. */ error instanceof Error ? error.message : 'Unknown error' }`); } } /** * As I mentioned before, the ImageResponse will need a React component. * So here is our component creation function. */ static async generateReactComponent(imageUrl, size, watermarkText) { // We will use create element instead of JSX. return createElement('div', { // Let's make it a div. Because we do not worry about SEO :D style: { // Use the size of the image to create a new image. width: `${size.width}px`, height: `${size.height}px`, // Make it relatively positioned. position: 'relative', // Use flexbox to center the text. display: 'flex', justifyContent: 'center', alignItems: 'center', }, }, [ createElement( 'img', // Create an image element as a child of the div. { src: imageUrl, // The source image url provided by the user. // Make it cover the div, already set the size. style: { width: '100%', height: '100%', objectFit: 'cover' }, }, ), createElement( 'span', // Create a span element to contain the watermark text. { style: { // Make it absolutely positioned and centered. position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%) rotate(-15deg)', // Make it semi-transparent with an inner space. padding: '0.5rem 1rem', borderRadius: '0.5rem', backgroundColor: 'rgba(0, 0, 0, 0.5)', // Make the text white and bold. color: 'white', fontSize: '2rem', fontWeight: 'bold', // Make it semi-transparent at overall. opacity: 0.5, }, }, watermarkText, // This span's child will be the watermark text. ), ]); } /** * This function will generate a BLOB response using the React component. * So we can just return this response on the route handler. * We will let the user provide the source image url and the watermark text. */ static async generateImageResponse(imageUrl, watermarkText) { // Get the size of the image. const size = await this.getImageSize(imageUrl); // Generate the React component depending on the size and the watermark text. const component = await this.generateReactComponent( imageUrl, size, watermarkText, ); /** * Create a new image response with the component and the size. * You can use custom fonts, emojis, etc. But we will keep it simple. */ return new ImageResponse(component, { width: size.width, height: size.height, }); } } export default Watermark;

And it’s done. Our class can create a new image with the watermark from the source image. Now it’s time to create a route handler to use it.

Route Handler

We will use the app router provided by Next.js. As an unusual case, Our root route will not be a page.jsx file. We will use route.js (God please forgive me) to handle the request.

javascript
// Import the Watermark class we created recently. import Watermark from "@/utilities/Watermark"; // Creating a named export with GET, will handle GET requests. export const GET = async (request) => { try { // Extract the search params from the request. const { searchParams } = request.nextUrl; // Get the source and text from the search params. const source = searchParams.get("source"); const text = searchParams.get("text"); // Check if the source and text are provided. if (!source) return new Response("Missing source", { status: 400 }); if (!text) return new Response("Missing text", { status: 400 }); /** * We will not validate inputs here. This is not the purpose of this article. * You can implement it in your own way. */ // Generate the image response. Look! It's so simple to use. const imageResponse = await Watermark.generateImageResponse(source, text); // Return the image response. TADA 🎉 return imageResponse; } catch (error) { /** * If something goes wrong, return an error response. * Remember, We throwed errors on the Watermark class. * So we must catch them somewhere. It's here. */ return new Response( /** * This is still not a good practice for production. * Explained before. */ error instanceof Error ? error.message : "Unknown error", { status: 500 } ); } };

We are here. Our route handler is ready. Now we can use it.

Usage

We can use it like this, just complete the url with the source image url and the watermark text:

bash
wget -O output.png "http://localhost:3000/api/watermark?source=&text="

And it’s done. We have a simple watermark API. Now we can use it.

Limitations

The source image may be protected by CORS. So you may need to add the source image url to the CORS allowed origins. This means DO NOT WATERMARK IMAGES YOU DO NOT OWN.

Conclusion

We have a simple watermark API. It’s not fully fledged. But it’s a good start. You can extend it with more features. Like different watermark positions, different fonts, colors, repetition, etc.

I hope you enjoyed this article. If you have any questions, you can ask me on X.


Album of the day:

Suggested Blog Posts