Content Thumbnails with Firebase Extensions

Learn to create beautiful previews for your app’s content using Image Processing API and Node Canvas.

Pavel Ryabov
13 min readAug 26, 2023

--

💻 Stack: Firebase, TypeScript
⏱️ Completion time: 1.5 hours

🙋‍♂️ Introduction

In this article, we will learn how to create beautiful dynamic previews for your application’s content. Using the Firebase toolkit and a couple of great packages, we will learn to generate fully dynamic and customizable link thumbnails that can be used for anything — from in-app news to special event promotions.

🤔 Why do we need dynamic thumbnails?

You have definitely seen link previews when sharing news and articles on the Web. They do wonders for boosting user engagement, adding credibility to the link, and providing a glimpse of the link’s content to motivate users to check it out. But what about applications? If you are promoting any type of content in your app and want people to share it online — dynamic thumbnails are your solution. They will motivate people to share, click, and install the app to check out the content.

When you click on links, most of them open web pages if the app isn’t installed, or open the content in the app, if it’s present. These types of links are referred to as dynamic (Firebase), app (Android), or universal (iOS) links. We will not be covering them in-depth in this blog (another blog, fully dedicated to them, is coming soon), so the HTML page behind the preview will be very simple. However, it’s a great practice to add a short promotional landing page or get extra creative and make a preview with Flutter Web or React that will showcase part of the content, giving users a sense of what your application does and making them want to check it out more.

👆 The Two Ways

In this article, we will cover two ways of generating thumbnails — using Image Processing Firebase Extension from Invertase and utilizing the node-canvas library. The first way is very simple and efficient, but won’t give you as much creative freedom as Canvas. Check out both and see what suits you best!

Preparation

To begin, we’ll create a new Firebase project. If you already have an existing project, feel free to use it. Our setup will involve the following Firebase resources: Firestore, Storage, Cloud Functions, and Hosting. Yes, it’s that easy — no external tools are needed. Let’s get started!

1. Storage

First, let’s upload a preview image for our post. This image will be the base of our thumbnail.

2. Firestore

Let’s add a simple entry to our Firestore database, emulating the potential structure of your content. Create a new collection called “posts” and add a title, subtitle, date, and description. Let’s also add a field called “imagePath” and put the path of the image we just uploaded to Storage there.

3. Hosting & Functions

Simply enable Functions and Hosting for the project. You can also add your custom domain if you prefer. No additional setup is required!

4. Firebase Extensions

Install the Image Processing API extension made by Invertase. It’s very straightforward — simply click `Install in Firebase console` and follow the instructions. No additional setup is needed.

For a more in-depth configuration of this extension, I recommend you check out the documentation here.

Setting up the Functions

Let’s move on to the most crucial part of the feature — the backend. We will implement this using Firebase Functions.

To get started, initialize Firebase for your project. Create a new folder for your functions and run:

firebase init

If you don’t have the Firebase CLI installed, simply run:

npm install -g firebase-tools

During the setup process, make sure to select both Functions and Hosting. Then, use the created Firebase project.

Next, select TypeScript, disable ESLint, and install dependencies with npm.

Set up the Hosting:

If you’d like to go ahead with a more advanced canvas implementation, add the package to the project by running:

npm install --save firebase canvas

Note: if you are using M1 Mac, check out this readme for additional installation instructions for node-canvas.

HTML page

Before we start working on the functions, let’s add an HTML page that will be opened by the link. We will be using this page to show dynamic OG (metadata) tags, including og:image tag for the thumbnail.

Create a folder called assets in lib, and create a folder html inside assets. Create the post.html file to the newly created /functions/lib/assets/html folder.

We have to put all of our static assets in the lib folder since it contains the JS files and TypeScript compiler will otherwise ignore all non-ts content when building the project.

We will be using simple handlebars to make tags dynamic. Here’s an example of what the post.html file might look like:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="apple-itunes-app" content="app-id=myAppID, affiliate-data=myAffiliateData, app-argument=myURL">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="My App">
<meta name="apple-mobile-web-app-title" content="My App">
<link rel="icon" type="image/png" href="">
<link rel="mask-icon" href="" color="#1d4695">
<meta name="application-name" content="My App">
<title>{{title}}</title>
<meta name="description" content="{{subtitle}}" />
<meta property="og:title" content="{{title}}" />
<meta property="og:description" content="{{subtitle}}" />
<meta property="og:image" content="{{imageUrl}}" />
<meta property="og:type" content="website" />
<meta property="og:locale" content="en_US" />
<meta name="twitter:card" content="summary_large_image"></meta>
<meta name="twitter:title" content="{{title}}"></meta>
<meta name="twitter:site" content="my-awesome-app.com"></meta>
<meta name="twitter:description" content="{{subtitle}}"></meta>
<meta name="twitter:image" content="{{imageUrl}}"></meta>
<link rel="apple-touch-icon" href="">
</head>
<body>
<h1>My Blog</h1>
</body>
<script>
// Optional: redirect users on mobile platforms to the according store
var result = detect.parse(navigator.userAgent);
if (result.os.family === 'iOS') {
window.location.href = '<https://apps.apple.com/us/app/YOUR_APP_ID>';
} else if (result.os.family.includes('Android')) {
window.location.href = '<https://play.google.com/store/apps/details?id=YOUR_APP_ID>';
}
</script>
</html>

Time to code!

The first function, called “post,” will serve the post webpage and dynamically set metadata for link previews. The second function, called “thumbnail” will be needed only if you choose to go with the Canvas way. It will handle the thumbnail generation and image processing.

/post function starter:

// Link structure: https://your-domain.com/post/:postID
exports.post = functions.https.onRequest(async (req, res) => {
// Get post ID from url params
const params = req.url.split('/');
const postID = params[params.length - 1].trim();
if (!postID) {
res.status(404).send('Post not found.');
}

// Get the post snapshot from Firestore
const postSnapshot = await firestore.doc(`posts/${postID}`).get();

// Check if the post exists
if (!postSnapshot.exists) {
res.status(404).send('Post not found.');
return;
}

// Extract the data from the post
const postData = postSnapshot.data()!;

// To be continued...
});

In this code section, we extract the post ID from the URL parameters. Then, we fetch the corresponding post snapshot from Firestore.

🔥 Firebase Extension Way — EASY

The easiest way to generate the thumbnail will be with the Image Processing API Extension from Invertase.

This amazing extension currently supports a wide range of operations that you can check out here. However, some functionality is still WIP and will be added soon, for example, the ability to add image overlays and custom fonts. If this functionality is crucial to you, skip forward to the Canvas Way section.

const operations = [{
// Pass image from Cloud Storage
operation: 'input',
type: 'gcs',
source: postData.imagePath
},
{
// Resize to the common thumbnail size
operation: 'resize',
width: '1200',
height: '630',
fit: 'cover'
},
{
// Add title text
operation: 'text',
value: postData.title.toUpperCase(),
font: '900 70px sans-serif',
strokeWidth: 1,
maxWidth: 1000,
wrap: true,
left: 50,
top: 450,
},
{
// Add subtitle text
operation: 'text',
value: postData.subtitle,
font: '400 40px sans-serif',
maxWidth: 1000,
textColor: '#ffffffcc',
wrap: true,
left: 50,
top: 520,
},
{
// Output as JPG, reduce quality
operation: 'output',
quality: 80,
format: 'jpeg'
},
];

// Encode query parameters from operations and pass it to the Cloud Function
const encodedOperations = encodeURIComponent(JSON.stringify(operations));
const imageUrl = `https://{FUNCTIONS_REGION}-{PROJECT_ID}.cloudfunctions.net/ext-image-processing-api-handler/process?operations=${encodedOperations}`;

Simple as that, we already have our thumbnail URL! Let’s pass it to the HTML page via handlebars and return the file.

// Return HTML file with metadata variables
const templatePath = path.join(__dirname, './assets/html/post.html');
var source = fs.readFileSync(templatePath, { encoding: 'utf-8' })
.replaceAll('{{title}}', postData.title)
.replaceAll('{{subtitle}}', postData.subtitle)
.replaceAll('{{imageUrl}}', imageUrl);

res.status(200).send(source);

Full index.ts file:

Everything is done! Skip to the Testing section. No need to create any other functions or files.

🏞️ Canvas Way — HARD

Node Canvas allows for a wide range of operations such as custom fonts, image & color overlays, and more. However, the setup for it is much harder than for the Invertase Extension, so proceed if you are ready for a challenge!

Remember /post function starter:

// Link structure: https://your-domain.com/post/:postID
exports.post = functions.https.onRequest(async (req, res) => {
// Get post ID from url params
const params = req.url.split('/');
const postID = params[params.length - 1].trim();
if (!postID) {
res.status(404).send('Post not found.');
}

// Get the post snapshot from Firestore
const postSnapshot = await firestore.doc(`posts/${postID}`).get();

// Check if the post exists
if (!postSnapshot.exists) {
res.status(404).send('Post not found.');
return;
}

// Extract the data from the post
const postData = postSnapshot.data()!;

// To be continued...
});

Create an encoded query parameters object from postData and pass it on to a different function. Since we are always calling the function from the same domain, we can use ref.headers.host, which will have a structure of [FUNCTIONS_REGION]-[PROJECT_ID]

// Create query params for the thumbnail
const queryParams = encodeURIComponent(JSON.stringify({
imagePath: postData.imagePath,
title: postData.title,
subtitle: postData.subtitle
}));

// This will be our thumbnail url
const imageUrl = `https://${ref.headers.host}/thumbnail?${queryParams}`;

Return the HTML file using handlebars to replace meta tags:

// Return HTML file with metadata variables
const templatePath = path.join(__dirname, './assets/html/post.html');
var source = fs.readFileSync(templatePath, { encoding: 'utf-8' })
.replaceAll('{{title}}', postData.title)
.replaceAll('{{subtitle}}', postData.subtitle)
.replaceAll('{{imageUrl}}', imageUrl);

res.status(200).send(source);

Now, let’s create the /thumbnail function that will be called every time the thumbnail is requested. This function generates and returns the image, so, for clarity, we could call it “thumbnail.jpg”.

/thumbnail function:

exports.thumbnail = functions.https.onRequest(async (req, res) => {
// Get query params
const { imagePath, title, subtitle } = req.query;

try {
// Get post image URL from Firebase Storage
const imageRef = getStorage(app).bucket().file(imagePath as string);
const imageUrl = await getDownloadURL(imageRef);

// Create post thumbnail
const thumbnail = await createThumbnail(imageUrl, title as string, subtitle as string);

// Attach created thumbnail to the response
res.writeHead(200, {
'Content-Type': 'image/jpeg',
'Content-Length': Buffer.byteLength(thumbnail)
}).end(thumbnail);

} catch (error) {
console.error("Error fetching object: ", error);
res.status(500).send("Error fetching object");
}
});

This function processes the query parameters we just created in /post, creates the thumbnail using the helper method (see more below), and returns the image as bytes.

We could have just sent the post ID and queried the snapshot again — it’s a cleaner solution, but it increases the number of Firestore calls and execution time, so I leave it up to you to decide.

Full index.ts file:

Image processing with Canvas

Time to work on the createThumbnail method. It will handle image processing, where we will crop the image, add a gradient overlay, and add text and graphics. Feel free to customize any parts of this code to create unique and engaging previews!

Since it’s a quite heavy utility function, let’s create a new file called helpers.ts in /src/util/ and start adding features one by one.

import { CanvasRenderingContext2D, createCanvas, loadImage, registerFont } from 'canvas';
import * as path from 'path';

export async function createThumbnail(imageUrl: string, title: string, subtitle: string): Promise<Buffer> {
// We will be writing the code here...
}
  1. First, we need to pre-load the fonts. Select the font you wish to use for the thumbnail and add the font files to the lib/assets/fonts folder. For this example, I've chosen the "Poppins" font for my banner.
// Step 1: Pre-load font
registerFont(path.join(__dirname, '../assets/fonts/Poppins-Black.ttf'), { family: 'Poppins', weight: '900' });
registerFont(path.join(__dirname, '../assets/fonts/Poppins-Regular.ttf'), { family: 'Poppins', weight: '500' });Next, we’ll create a canvas and load the image using its URL.

Note: 1200x630 is the recommended resolution for thumbnails that works well on all platforms

// Step 2: Create a canvas and load the image
const canvas = createCanvas(1200, 630);
const ctx = canvas.getContext('2d');
const image = await loadImage(imageUrl);
  1. Next, we’ll draw our background image on the canvas and ensure it is properly scaled to avoid any stretching:
// Step 3: Draw the background image on the canvas, scaling it to cover the entire area
ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
const imageAspectRatio = image.width / image.height;
const canvasAspectRatio = canvas.width / canvas.height;

let scale = 1;
let offsetX = 0;
let offsetY = 0;

if (imageAspectRatio > canvasAspectRatio) {
scale = canvas.height / image.height;
offsetX = (canvas.width - image.width * scale) / 2;
} else {
scale = canvas.width / image.width;
offsetY = (canvas.height - image.height * scale) / 2;
}

ctx.drawImage(image, offsetX, offsetY, image.width * scale, image.height * scale);
  1. Let’s add a linear gradient so our text looks good on any image:
// Step 4: Create a dark linear gradient and fill the entire canvas
const gradient = ctx.createLinearGradient(0, 0, 0, canvas.height);
gradient.addColorStop(0, '#000000B3'); // Start color
gradient.addColorStop(0.5, '#00000059'); // Middle color
gradient.addColorStop(1, '#000000E6'); // End color

ctx.fillStyle = gradient;
ctx.fillRect(0, 0, canvas.width, canvas.height);
  1. Before proceeding with the text, we need to think about wrapping. What if the text is too long and we have to move to a new line? Since the canvas cannot automatically calculate this, let’s create a drawWrappedText() function to assist it. This function will measure the text length and decide when a line break is necessary to fit the text properly. We will place this function in helpers.ts, after the thumbnail endpoint code:
function drawWrappedText(
ctx: CanvasRenderingContext2D,
text: string,
x: number,
y: number,
maxWidth:
number,
fontSize: number
) {
const lineHeight = 10; // Can adjust this depending on the font
const words = text.split(' ');
let line = '';

for (let i = words.length - 1; i >= 0; i--) {
const testLine = words[i] + ' ' + line;
const metrics = ctx.measureText(testLine);
const testWidth = metrics.width;

if (testWidth > maxWidth) {
ctx.fillText(line, x, y);
line = words[i] + ' ';
y -= fontSize + lineHeight;
} else {
line = testLine;
}
}
ctx.fillText(line, x, y);

// Returns the Y-position of the text
// so we know where to put the next element
return y - fontSize - lineHeight;
}
  1. It’s time to add our texts to the thumbnail. For this example, I’ve chosen a layout where the title and subtitle will be placed in the bottom left corner. Feel free to experiment with different layouts to achieve the desired effect!
// Step 5: Define font style for the subtitle and draw it
ctx.font = `500 30px 'Poppins'`; // fontWeight fontSize fontFamily
ctx.fillStyle = '#FFFFFFCC'; // color

// Draw the title at (50, 550) with max width of 1000 and fontSize of 30
const subtitleEndY = drawWrappedText(ctx, subtitle, 50, 550, 900, 30);

// Step 6: Define font style for the title and draw it
ctx.font = `900 60px 'Poppins'`;
ctx.fillStyle = '#FFFFFF';

// Draw the title on top of the subtitle (with 15px spacing) with max width of 1000 and fontSize of 60
drawWrappedText(ctx, title.toUpperCase(), 50, subtitleEndY - 15, 900, 60);
  1. Almost done! The final touch is to add a logo overlay. With a logo overlay, your thumbnails will look more professional and branded, which will attract users. Place the logo image file into the lib/assets/images folder.
// Step 7: Draw the overlay logo to the top left corner
const overlayLogo = await loadImage(path.join(__dirname, '../assets/images/logo.svg'));
ctx.drawImage(overlayLogo, 50, 50, overlayLogo.width, overlayLogo.height);
  1. Our thumbnail is finally ready! Let’s compress it and return the buffer:
// Step 8: Compress the resulting image and return it as a buffer
return canvas.toBuffer('image/jpeg', { quality: 0.75 });

Full helpers.ts file:

Your folder structure should look similar to this:

Testing

Don’t forget to test if everything works fine before deploying. Even though you cannot see the preview locally by sharing it on social media, you can still run npm run serve to start a local emulator and test the endpoints locally with some fake data (using Postman or another API debugging tool)

Optional: Firebase Hosting

To host the Functions with Firebase Hosting and use your custom domain, follow these simple steps:

  1. In the package.json file, update the deploy script to "deploy": "firebase deploy --only functions,hosting". This change will enable automatic deployment of functions to Hosting.
  2. In the firebase.json file, modify the "hosting" configuration to look like this:
...
"hosting": {
"public": "public",
"ignore": [
"firebase.json",
"**/.*",
"**/node_modules/**"
],
"rewrites": [
// Add all new endpoints here to use them with Hosting
{
"source": "/post/**",
"function": "post"
},
{
"source": "/thumbnail**",
"function": "thumbnail"
}
]
},
...

Conclusion

We are all set to deploy our code. Simply run npm run deploy and patiently wait for the functions to deploy.

After deployment, you can test the link preview functionality by sharing a Hosting/Functions link through social media or a messenger. This way, you can observe the link preview in action and see how it enhances user engagement and boosts credibility for your application content.

Enjoy your dynamic link previews!

With Hosting: https://FIREBASE_PROJECT_NAME.web.app/post/:postID (or your custom domain)
Without Hosting: https://REGION-FIREBASE_PROJECT_NAME.cloudfunctions.net/post/:postID
The result with Canvas
The result with Image Processing API

The full example is available on GitHub.

Conclusion

Today we learned how to create dynamic link previews with Image Processing API and Canvas. The Canvas implementation is tricky and requires quite a bit of code, so if you encounter any problems or have any questions, feel free to reach out in the comments — I’m always glad to help!

If you enjoyed this article, please take a second to give it a clap! 👏

📣 Follow me on Twitter (𝕏) to keep in touch and receive a daily dose of Firebase/Flutter blogs and some fun tips and tricks!

More articles

--

--